Initial portfolio CMS: Fastify API + Vite SPA
Single-container app with Postgres catalog, Typesense search, Authelia Remote-User admin, portfolio views, resume/PDF, media uploads, and ArtStation import tooling.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import "dotenv/config";
|
||||
import path from "node:path";
|
||||
|
||||
function env(key: string, fallback = ""): string {
|
||||
return process.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
function envBool(key: string, fallback: boolean): boolean {
|
||||
const v = process.env[key];
|
||||
if (v === undefined) return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
|
||||
}
|
||||
|
||||
function envInt(key: string, fallback: number): number {
|
||||
const v = process.env[key];
|
||||
if (!v) return fallback;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
const dbHost = env("DB_HOST", "localhost");
|
||||
const dbPort = env("DB_PORT", "5432");
|
||||
const dbName = env("DB_NAME", "jmartgraphix-com");
|
||||
const dbUser = env("DB_USER", "jmartgraphix-com");
|
||||
const dbPass = env("DB_PASS", "");
|
||||
|
||||
export const config = {
|
||||
nodeEnv: env("NODE_ENV", "development"),
|
||||
isProd: env("NODE_ENV", "development") === "production",
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: envInt("PORT", 3000),
|
||||
publicUrl: env("PUBLIC_URL", "http://localhost:3000").replace(/\/$/, ""),
|
||||
databaseUrl:
|
||||
env("DATABASE_URL") ||
|
||||
`postgresql://${encodeURIComponent(dbUser)}:${encodeURIComponent(dbPass)}@${dbHost}:${dbPort}/${dbName}`,
|
||||
typesense: {
|
||||
host: env("TYPESENSE_HOST", "localhost"),
|
||||
port: envInt("TYPESENSE_PORT", 8108),
|
||||
protocol: env("TYPESENSE_PROTOCOL", "http") as "http" | "https",
|
||||
apiKey: env("TYPESENSE_API_KEY", "typesense_dev_key"),
|
||||
},
|
||||
imgproxy: {
|
||||
host: env("IMGPROXY_HOST", ""),
|
||||
key: env("IMGPROXY_KEY", ""),
|
||||
salt: env("IMGPROXY_SALT", ""),
|
||||
},
|
||||
adminGroups: env("ADMIN_GROUPS", "lldap_admin,admin,portfolio_admin")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
requireRemoteUser: envBool("REQUIRE_REMOTE_USER", true),
|
||||
uploadDir: env("UPLOAD_DIR", path.resolve(process.cwd(), "data/uploads")),
|
||||
maxUploadMb: envInt("MAX_UPLOAD_MB", 50),
|
||||
siteName: env("SITE_NAME", "jmartgraphix"),
|
||||
siteTagline: env("SITE_TAGLINE", "Creative Professional Portfolio"),
|
||||
publicDir: env("PUBLIC_DIR", path.resolve(process.cwd(), "public")),
|
||||
clientDist: env("CLIENT_DIST", path.resolve(process.cwd(), "public")),
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import swagger from "@fastify/swagger";
|
||||
import swaggerUi from "@fastify/swagger-ui";
|
||||
import { config } from "./config.js";
|
||||
import { prisma } from "./lib/prisma.js";
|
||||
import { ensureUploadDirs } from "./lib/media.js";
|
||||
import { reindexAllProjects } from "./lib/typesense.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { publicRoutes } from "./routes/public.js";
|
||||
import { adminProjectRoutes } from "./routes/admin-projects.js";
|
||||
import { adminMetaRoutes } from "./routes/admin-meta.js";
|
||||
import { resumePdfRoutes } from "./routes/resume-pdf.js";
|
||||
|
||||
async function waitForDb(retries = 30): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(`Database not ready (${i + 1}/${retries})…`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
throw new Error("Database connection failed");
|
||||
}
|
||||
|
||||
async function runMigrations(): Promise<void> {
|
||||
// Prefer prisma migrate deploy when migrations exist
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync("npx prisma migrate deploy", {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
console.log("Database migrations applied");
|
||||
} catch {
|
||||
console.warn("migrate deploy failed, falling back to db push");
|
||||
execSync("npx prisma db push --skip-generate", {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
console.log("Database schema pushed");
|
||||
}
|
||||
try {
|
||||
execSync("npx tsx prisma/seed.ts", {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Seed skipped or failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.env.DATABASE_URL = config.databaseUrl;
|
||||
|
||||
await ensureUploadDirs();
|
||||
await waitForDb();
|
||||
await runMigrations();
|
||||
|
||||
try {
|
||||
const n = await reindexAllProjects();
|
||||
console.log(`Typesense index ready (${n} projects)`);
|
||||
} catch (err) {
|
||||
console.warn("Typesense index failed (search may be degraded):", err);
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
trustProxy: true,
|
||||
bodyLimit: config.maxUploadMb * 1024 * 1024,
|
||||
});
|
||||
|
||||
await app.register(cors, { origin: true, credentials: true });
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: config.maxUploadMb * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: "jmartgraphix Portfolio API",
|
||||
description: "Public and admin API for the jmartgraphix portfolio CMS",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: config.publicUrl }],
|
||||
tags: [
|
||||
{ name: "public", description: "Public read endpoints" },
|
||||
{ name: "admin", description: "Admin endpoints (Authelia Remote-User)" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: "/api/docs",
|
||||
uiConfig: { docExpansion: "list" },
|
||||
});
|
||||
|
||||
await healthRoutes(app);
|
||||
await publicRoutes(app);
|
||||
await adminProjectRoutes(app);
|
||||
await adminMetaRoutes(app);
|
||||
await resumePdfRoutes(app);
|
||||
|
||||
// Uploads
|
||||
await app.register(fastifyStatic, {
|
||||
root: config.uploadDir,
|
||||
prefix: "/uploads/",
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
// .well-known (matrix, webfinger) + built SPA
|
||||
const publicRoot = config.clientDist;
|
||||
if (fs.existsSync(publicRoot)) {
|
||||
// Explicit .well-known with correct content types
|
||||
app.get("/.well-known/*", async (req, reply) => {
|
||||
const sub = (req.params as { "*": string })["*"];
|
||||
const filePath = path.join(publicRoot, ".well-known", sub);
|
||||
if (!filePath.startsWith(path.join(publicRoot, ".well-known"))) {
|
||||
return reply.code(403).send("Forbidden");
|
||||
}
|
||||
try {
|
||||
const data = await fs.promises.readFile(filePath);
|
||||
// matrix & webfinger are JSON without extension often
|
||||
const isJson =
|
||||
sub.includes("webfinger") ||
|
||||
sub.includes("matrix") ||
|
||||
filePath.endsWith(".json");
|
||||
return reply
|
||||
.type(isJson ? "application/json" : "application/octet-stream")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.send(data);
|
||||
} catch {
|
||||
return reply.code(404).send({ error: "Not found" });
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: publicRoot,
|
||||
prefix: "/",
|
||||
wildcard: false,
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
// SPA fallback for client routes (not API, not uploads, not well-known)
|
||||
app.setNotFoundHandler(async (req, reply) => {
|
||||
if (
|
||||
req.url.startsWith("/api/") ||
|
||||
req.url.startsWith("/uploads/") ||
|
||||
req.url.startsWith("/.well-known/")
|
||||
) {
|
||||
return reply.code(404).send({ error: "Not found" });
|
||||
}
|
||||
const indexPath = path.join(publicRoot, "index.html");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return reply.type("text/html").send(await fs.promises.readFile(indexPath, "utf8"));
|
||||
}
|
||||
return reply.code(404).send("Not found");
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
console.log(`Server listening on http://${config.host}:${config.port}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
function header(req: FastifyRequest, name: string): string | undefined {
|
||||
const v = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return v;
|
||||
}
|
||||
|
||||
export function getRemoteUser(req: FastifyRequest): AuthUser | null {
|
||||
const username = header(req, "remote-user") || header(req, "Remote-User");
|
||||
if (!username) return null;
|
||||
const groupsRaw =
|
||||
header(req, "remote-groups") || header(req, "Remote-Groups") || "";
|
||||
const groups = groupsRaw
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
username,
|
||||
name: header(req, "remote-name") || header(req, "Remote-Name"),
|
||||
email: header(req, "remote-email") || header(req, "Remote-Email"),
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAdminUser(user: AuthUser): boolean {
|
||||
if (config.adminGroups.length === 0) return true;
|
||||
return user.groups.some((g) => config.adminGroups.includes(g));
|
||||
}
|
||||
|
||||
/** Dev bypass when REQUIRE_REMOTE_USER=false */
|
||||
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): AuthUser | null {
|
||||
const user = getRemoteUser(req);
|
||||
if (user) {
|
||||
if (!isAdminUser(user)) {
|
||||
reply.code(403).send({ error: "Forbidden", message: "Insufficient group membership" });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
if (!config.requireRemoteUser) {
|
||||
return {
|
||||
username: "dev-admin",
|
||||
name: "Dev Admin",
|
||||
groups: config.adminGroups.length ? [config.adminGroups[0]] : ["admin"],
|
||||
};
|
||||
}
|
||||
reply.code(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "Missing Remote-User header. Authenticate via Authelia SSO.",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export async function ensureUploadDirs(): Promise<void> {
|
||||
await fs.mkdir(path.join(config.uploadDir, "originals"), { recursive: true });
|
||||
await fs.mkdir(path.join(config.uploadDir, "thumbs"), { recursive: true });
|
||||
await fs.mkdir(path.join(config.uploadDir, "videos"), { recursive: true });
|
||||
}
|
||||
|
||||
function extFromMime(mime: string, originalName?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/avif": ".avif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
};
|
||||
if (map[mime]) return map[mime];
|
||||
if (originalName) {
|
||||
const e = path.extname(originalName).toLowerCase();
|
||||
if (e) return e;
|
||||
}
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
export async function saveImageUpload(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
originalName?: string
|
||||
): Promise<{
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sizeBytes: number;
|
||||
}> {
|
||||
await ensureUploadDirs();
|
||||
const id = crypto.randomBytes(16).toString("hex");
|
||||
const ext = extFromMime(mimeType, originalName);
|
||||
const filename = `${id}${ext}`;
|
||||
const originalPath = path.join(config.uploadDir, "originals", filename);
|
||||
const thumbName = `${id}.webp`;
|
||||
const thumbPath = path.join(config.uploadDir, "thumbs", thumbName);
|
||||
|
||||
const image = sharp(buffer, { failOn: "none" });
|
||||
const meta = await image.metadata();
|
||||
await fs.writeFile(originalPath, buffer);
|
||||
|
||||
await sharp(buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toFile(thumbPath);
|
||||
|
||||
return {
|
||||
url: `/uploads/originals/${filename}`,
|
||||
thumbnailUrl: `/uploads/thumbs/${thumbName}`,
|
||||
filename: originalName || filename,
|
||||
mimeType,
|
||||
width: meta.width ?? 0,
|
||||
height: meta.height ?? 0,
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveVideoUpload(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
originalName?: string
|
||||
): Promise<{
|
||||
url: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}> {
|
||||
await ensureUploadDirs();
|
||||
const id = crypto.randomBytes(16).toString("hex");
|
||||
const ext = extFromMime(mimeType, originalName);
|
||||
const filename = `${id}${ext}`;
|
||||
const videoPath = path.join(config.uploadDir, "videos", filename);
|
||||
await fs.writeFile(videoPath, buffer);
|
||||
return {
|
||||
url: `/uploads/videos/${filename}`,
|
||||
filename: originalName || filename,
|
||||
mimeType,
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadRemoteImage(url: string): Promise<{
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sizeBytes: number;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "jmartgraphix-importer/1.0" },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const mime = res.headers.get("content-type")?.split(";")[0] || "image/jpeg";
|
||||
if (!mime.startsWith("image/")) return null;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const name = path.basename(new URL(url).pathname) || "import.jpg";
|
||||
return saveImageUpload(buf, mime, name);
|
||||
} catch (err) {
|
||||
console.warn("downloadRemoteImage failed:", url, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse YouTube / Vimeo URLs into embed metadata */
|
||||
export function parseVideoUrl(input: string): {
|
||||
videoSource: "youtube" | "vimeo" | "external";
|
||||
videoId: string | null;
|
||||
externalUrl: string;
|
||||
embedUrl: string | null;
|
||||
} {
|
||||
const url = input.trim();
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// YouTube
|
||||
if (u.hostname.includes("youtube.com") || u.hostname === "youtu.be") {
|
||||
let id: string | null = null;
|
||||
if (u.hostname === "youtu.be") id = u.pathname.slice(1).split("/")[0];
|
||||
else if (u.pathname.startsWith("/embed/")) id = u.pathname.split("/")[2];
|
||||
else if (u.pathname.startsWith("/shorts/")) id = u.pathname.split("/")[2];
|
||||
else id = u.searchParams.get("v");
|
||||
return {
|
||||
videoSource: "youtube",
|
||||
videoId: id,
|
||||
externalUrl: url,
|
||||
embedUrl: id ? `https://www.youtube.com/embed/${id}` : null,
|
||||
};
|
||||
}
|
||||
// Vimeo
|
||||
if (u.hostname.includes("vimeo.com")) {
|
||||
const parts = u.pathname.split("/").filter(Boolean);
|
||||
const id = parts[parts.length - 1]?.match(/^\d+$/) ? parts[parts.length - 1] : null;
|
||||
return {
|
||||
videoSource: "vimeo",
|
||||
videoId: id,
|
||||
externalUrl: url,
|
||||
embedUrl: id ? `https://player.vimeo.com/video/${id}` : null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
return {
|
||||
videoSource: "external",
|
||||
videoId: null,
|
||||
externalUrl: url,
|
||||
embedUrl: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export const projectPublicInclude = {
|
||||
categories: { include: { category: true } },
|
||||
tags: { include: { tag: true } },
|
||||
thumbnail: true,
|
||||
media: { orderBy: { sortOrder: "asc" as const } },
|
||||
} as const;
|
||||
|
||||
export function serializeProject<
|
||||
T extends {
|
||||
categories: { category: unknown }[];
|
||||
tags: { tag: unknown }[];
|
||||
externalLinks: unknown;
|
||||
},
|
||||
>(p: T) {
|
||||
return {
|
||||
...p,
|
||||
categories: p.categories.map((c) => c.category),
|
||||
tags: p.tags.map((t) => t.tag),
|
||||
externalLinks: p.externalLinks ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import slugify from "slugify";
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
export function makeSlug(input: string): string {
|
||||
return slugify(input, { lower: true, strict: true, trim: true }) || "item";
|
||||
}
|
||||
|
||||
export async function uniqueProjectSlug(title: string, excludeId?: string): Promise<string> {
|
||||
const base = makeSlug(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
const existing = await prisma.project.findUnique({ where: { slug } });
|
||||
if (!existing || existing.id === excludeId) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uniqueCategorySlug(name: string, excludeId?: string): Promise<string> {
|
||||
const base = makeSlug(name);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
const existing = await prisma.category.findUnique({ where: { slug } });
|
||||
if (!existing || existing.id === excludeId) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uniqueTagSlug(name: string, excludeId?: string): Promise<string> {
|
||||
const base = makeSlug(name);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
const existing = await prisma.tag.findUnique({ where: { slug } });
|
||||
if (!existing || existing.id === excludeId) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Typesense from "typesense";
|
||||
import type { Client } from "typesense";
|
||||
import { config } from "../config.js";
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
const COLLECTION = "projects";
|
||||
|
||||
let client: Client | null = null;
|
||||
|
||||
export function getTypesense(): Client {
|
||||
if (!client) {
|
||||
client = new Typesense.Client({
|
||||
nodes: [
|
||||
{
|
||||
host: config.typesense.host,
|
||||
port: config.typesense.port,
|
||||
protocol: config.typesense.protocol,
|
||||
},
|
||||
],
|
||||
apiKey: config.typesense.apiKey,
|
||||
connectionTimeoutSeconds: 5,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
const schema = {
|
||||
name: COLLECTION,
|
||||
fields: [
|
||||
{ name: "id", type: "string" as const },
|
||||
{ name: "title", type: "string" as const },
|
||||
{ name: "slug", type: "string" as const },
|
||||
{ name: "description", type: "string" as const },
|
||||
{ name: "shortDescription", type: "string" as const, optional: true },
|
||||
{ name: "categories", type: "string[]" as const, facet: true },
|
||||
{ name: "categorySlugs", type: "string[]" as const, facet: true },
|
||||
{ name: "tags", type: "string[]" as const, facet: true },
|
||||
{ name: "software", type: "string[]" as const, facet: true },
|
||||
{ name: "featured", type: "bool" as const, facet: true },
|
||||
{ name: "visibility", type: "string" as const, facet: true },
|
||||
{ name: "displayPriority", type: "int32" as const },
|
||||
{ name: "date", type: "int64" as const, optional: true },
|
||||
],
|
||||
default_sorting_field: "displayPriority",
|
||||
};
|
||||
|
||||
export async function ensureTypesenseCollection(): Promise<void> {
|
||||
const ts = getTypesense();
|
||||
try {
|
||||
await ts.collections(COLLECTION).retrieve();
|
||||
} catch {
|
||||
await ts.collections().create(schema);
|
||||
}
|
||||
}
|
||||
|
||||
function toDoc(p: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
shortDescription: string | null;
|
||||
software: string[];
|
||||
featured: boolean;
|
||||
visibility: string;
|
||||
displayPriority: number;
|
||||
date: Date | null;
|
||||
categories: { category: { name: string; slug: string } }[];
|
||||
tags: { tag: { name: string; slug: string } }[];
|
||||
}) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
description: p.description,
|
||||
shortDescription: p.shortDescription ?? "",
|
||||
categories: p.categories.map((c) => c.category.name),
|
||||
categorySlugs: p.categories.map((c) => c.category.slug),
|
||||
tags: p.tags.map((t) => t.tag.name),
|
||||
software: p.software,
|
||||
featured: p.featured,
|
||||
visibility: p.visibility,
|
||||
displayPriority: p.displayPriority,
|
||||
date: p.date ? Math.floor(p.date.getTime() / 1000) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reindexAllProjects(): Promise<number> {
|
||||
await ensureTypesenseCollection();
|
||||
const ts = getTypesense();
|
||||
try {
|
||||
await ts.collections(COLLECTION).delete();
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
await ts.collections().create(schema);
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
include: {
|
||||
categories: { include: { category: true } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) return 0;
|
||||
|
||||
const docs = projects.map(toDoc);
|
||||
await ts.collections(COLLECTION).documents().import(docs, { action: "upsert" });
|
||||
return docs.length;
|
||||
}
|
||||
|
||||
export async function indexProject(projectId: string): Promise<void> {
|
||||
const p = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
categories: { include: { category: true } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
if (!p) {
|
||||
await removeProjectFromIndex(projectId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureTypesenseCollection();
|
||||
await getTypesense().collections(COLLECTION).documents().upsert(toDoc(p));
|
||||
} catch (err) {
|
||||
console.warn("Typesense indexProject failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProjectFromIndex(projectId: string): Promise<void> {
|
||||
try {
|
||||
await getTypesense().collections(COLLECTION).documents(projectId).delete();
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchProjects(opts: {
|
||||
q?: string;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
featured?: boolean;
|
||||
visibility?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: string;
|
||||
}) {
|
||||
await ensureTypesenseCollection();
|
||||
const filters: string[] = [];
|
||||
if (opts.visibility) filters.push(`visibility:=${opts.visibility}`);
|
||||
else filters.push("visibility:=published");
|
||||
if (opts.category) filters.push(`categorySlugs:=[${opts.category}]`);
|
||||
if (opts.tag) filters.push(`tags:=[${opts.tag}]`);
|
||||
if (opts.featured !== undefined) filters.push(`featured:=${opts.featured}`);
|
||||
|
||||
let sortBy = "displayPriority:asc,date:desc";
|
||||
if (opts.sortBy === "date") sortBy = "date:desc";
|
||||
else if (opts.sortBy === "title") sortBy = "title:asc";
|
||||
else if (opts.sortBy === "priority") sortBy = "displayPriority:asc,date:desc";
|
||||
|
||||
const result = await getTypesense()
|
||||
.collections(COLLECTION)
|
||||
.documents()
|
||||
.search({
|
||||
q: opts.q?.trim() || "*",
|
||||
query_by: "title,description,shortDescription,tags,categories,software",
|
||||
filter_by: filters.join(" && "),
|
||||
sort_by: sortBy,
|
||||
page: opts.page ?? 1,
|
||||
per_page: opts.perPage ?? 24,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { requireAdmin } from "../lib/auth.js";
|
||||
import { uniqueCategorySlug, uniqueTagSlug, makeSlug } from "../lib/slug.js";
|
||||
import { reindexAllProjects } from "../lib/typesense.js";
|
||||
|
||||
export async function adminMetaRoutes(app: FastifyInstance) {
|
||||
// Categories
|
||||
app.get("/api/v1/admin/categories", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return prisma.category.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/categories", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
const slug = body.slug || (await uniqueCategorySlug(body.name));
|
||||
const cat = await prisma.category.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
slug,
|
||||
description: body.description,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send(cat);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/v1/admin/categories/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
const existing = await prisma.category.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||
let slug = existing.slug;
|
||||
if (body.slug) slug = body.slug;
|
||||
else if (body.name) slug = await uniqueCategorySlug(body.name, existing.id);
|
||||
return prisma.category.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(body.name ? { name: body.name } : {}),
|
||||
slug,
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/v1/admin/categories/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
await prisma.category.delete({ where: { id: req.params.id } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Tags
|
||||
app.get("/api/v1/admin/tags", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return prisma.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/tags", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z.object({ name: z.string().min(1) }).parse(req.body);
|
||||
const slug = await uniqueTagSlug(body.name);
|
||||
const tag = await prisma.tag.create({ data: { name: body.name, slug } });
|
||||
return reply.code(201).send(tag);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/v1/admin/tags/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
await prisma.tag.delete({ where: { id: req.params.id } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Portfolio views
|
||||
app.get("/api/v1/admin/views", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return prisma.portfolioView.findMany({
|
||||
include: {
|
||||
categoryPriorities: {
|
||||
orderBy: { priority: "asc" },
|
||||
include: { category: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/views", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
slug: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
defaultSort: z.string().optional(),
|
||||
showOthers: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
categories: z
|
||||
.array(z.object({ categoryId: z.string(), priority: z.number().int() }))
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
.parse(req.body);
|
||||
const slug = body.slug || makeSlug(body.name);
|
||||
const view = await prisma.portfolioView.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
slug,
|
||||
description: body.description,
|
||||
defaultSort: body.defaultSort ?? "priority",
|
||||
showOthers: body.showOthers ?? true,
|
||||
isActive: body.isActive ?? true,
|
||||
categoryPriorities: {
|
||||
create: body.categories.map((c) => ({
|
||||
categoryId: c.categoryId,
|
||||
priority: c.priority,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
categoryPriorities: { include: { category: true }, orderBy: { priority: "asc" } },
|
||||
},
|
||||
});
|
||||
return reply.code(201).send(view);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/v1/admin/views/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z.string().optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
defaultSort: z.string().optional(),
|
||||
showOthers: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
categories: z
|
||||
.array(z.object({ categoryId: z.string(), priority: z.number().int() }))
|
||||
.optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
const existing = await prisma.portfolioView.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||
|
||||
if (body.categories) {
|
||||
await prisma.portfolioViewCategory.deleteMany({ where: { viewId: existing.id } });
|
||||
await prisma.portfolioViewCategory.createMany({
|
||||
data: body.categories.map((c) => ({
|
||||
viewId: existing.id,
|
||||
categoryId: c.categoryId,
|
||||
priority: c.priority,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.portfolioView.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(body.name ? { name: body.name } : {}),
|
||||
...(body.slug ? { slug: body.slug } : {}),
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.defaultSort ? { defaultSort: body.defaultSort } : {}),
|
||||
...(body.showOthers !== undefined ? { showOthers: body.showOthers } : {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
},
|
||||
include: {
|
||||
categoryPriorities: { include: { category: true }, orderBy: { priority: "asc" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/v1/admin/views/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
await prisma.portfolioView.delete({ where: { id: req.params.id } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Resume
|
||||
app.get("/api/v1/admin/resume", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return (
|
||||
(await prisma.resume.findFirst({ where: { isActive: true } })) ||
|
||||
(await prisma.resume.findFirst({ orderBy: { updatedAt: "desc" } }))
|
||||
);
|
||||
});
|
||||
|
||||
app.put("/api/v1/admin/resume", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
fullName: z.string().min(1),
|
||||
title: z.string().optional().default(""),
|
||||
email: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
location: z.string().optional().nullable(),
|
||||
website: z.string().optional().nullable(),
|
||||
summary: z.string().optional().default(""),
|
||||
sections: z.any().optional().default([]),
|
||||
htmlContent: z.string().optional().nullable(),
|
||||
theme: z.string().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const existing = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||
if (existing) {
|
||||
return prisma.resume.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
fullName: body.fullName,
|
||||
title: body.title,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
location: body.location,
|
||||
website: body.website,
|
||||
summary: body.summary,
|
||||
sections: body.sections,
|
||||
htmlContent: body.htmlContent,
|
||||
theme: body.theme ?? existing.theme,
|
||||
},
|
||||
});
|
||||
}
|
||||
return prisma.resume.create({
|
||||
data: {
|
||||
isActive: true,
|
||||
fullName: body.fullName,
|
||||
title: body.title,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
location: body.location,
|
||||
website: body.website,
|
||||
summary: body.summary,
|
||||
sections: body.sections,
|
||||
htmlContent: body.htmlContent,
|
||||
theme: body.theme ?? "dark",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Site settings
|
||||
app.get("/api/v1/admin/settings", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const rows = await prisma.siteSetting.findMany();
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
});
|
||||
|
||||
app.put("/api/v1/admin/settings/site", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const value = req.body as object;
|
||||
return prisma.siteSetting.upsert({
|
||||
where: { key: "site" },
|
||||
create: { key: "site", value },
|
||||
update: { value },
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/reindex", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const count = await reindexAllProjects();
|
||||
return { ok: true, indexed: count };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { Visibility } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { requireAdmin } from "../lib/auth.js";
|
||||
import { uniqueProjectSlug } from "../lib/slug.js";
|
||||
import { projectPublicInclude, serializeProject } from "../lib/project-include.js";
|
||||
import { indexProject, removeProjectFromIndex } from "../lib/typesense.js";
|
||||
import { parseVideoUrl, saveImageUpload, saveVideoUpload } from "../lib/media.js";
|
||||
|
||||
const projectBody = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional().default(""),
|
||||
shortDescription: z.string().optional().nullable(),
|
||||
date: z.string().optional().nullable(),
|
||||
software: z.array(z.string()).optional().default([]),
|
||||
externalLinks: z
|
||||
.array(z.object({ label: z.string(), url: z.string().url() }))
|
||||
.optional()
|
||||
.default([]),
|
||||
featured: z.boolean().optional().default(false),
|
||||
displayPriority: z.number().int().optional().default(0),
|
||||
visibility: z.enum(["published", "draft", "private"]).optional().default("draft"),
|
||||
categoryIds: z.array(z.string()).optional().default([]),
|
||||
tagNames: z.array(z.string()).optional().default([]),
|
||||
thumbnailId: z.string().optional().nullable(),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
async function syncTags(projectId: string, tagNames: string[]) {
|
||||
await prisma.projectTag.deleteMany({ where: { projectId } });
|
||||
for (const raw of tagNames) {
|
||||
const name = raw.trim();
|
||||
if (!name) continue;
|
||||
const { makeSlug } = await import("../lib/slug.js");
|
||||
const slug = makeSlug(name);
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { slug },
|
||||
create: { name, slug },
|
||||
update: { name },
|
||||
});
|
||||
await prisma.projectTag.create({ data: { projectId, tagId: tag.id } });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCategories(projectId: string, categoryIds: string[]) {
|
||||
await prisma.projectCategory.deleteMany({ where: { projectId } });
|
||||
for (const categoryId of categoryIds) {
|
||||
await prisma.projectCategory.create({ data: { projectId, categoryId } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminProjectRoutes(app: FastifyInstance) {
|
||||
app.get("/api/v1/admin/me", async (req, reply) => {
|
||||
const user = requireAdmin(req, reply);
|
||||
if (!user) return;
|
||||
return user;
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: { q?: string; visibility?: string; page?: string; perPage?: string };
|
||||
}>("/api/v1/admin/projects", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1);
|
||||
const perPage = Math.min(100, parseInt(req.query.perPage || "50", 10) || 50);
|
||||
const where = {
|
||||
...(req.query.visibility
|
||||
? { visibility: req.query.visibility as Visibility }
|
||||
: {}),
|
||||
...(req.query.q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: req.query.q, mode: "insensitive" as const } },
|
||||
{ description: { contains: req.query.q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [total, projects] = await Promise.all([
|
||||
prisma.project.count({ where }),
|
||||
prisma.project.findMany({
|
||||
where,
|
||||
include: projectPublicInclude,
|
||||
orderBy: [{ displayPriority: "asc" }, { updatedAt: "desc" }],
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
data: projects.map(serializeProject),
|
||||
meta: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
return serializeProject(project);
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/projects", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = projectBody.parse(req.body);
|
||||
const slug = body.slug || (await uniqueProjectSlug(body.title));
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
slug,
|
||||
description: body.description,
|
||||
shortDescription: body.shortDescription,
|
||||
date: body.date ? new Date(body.date) : null,
|
||||
software: body.software,
|
||||
externalLinks: body.externalLinks,
|
||||
featured: body.featured,
|
||||
displayPriority: body.displayPriority,
|
||||
visibility: body.visibility as Visibility,
|
||||
thumbnailId: body.thumbnailId,
|
||||
publishedAt: body.visibility === "published" ? new Date() : null,
|
||||
},
|
||||
});
|
||||
await syncCategories(project.id, body.categoryIds);
|
||||
await syncTags(project.id, body.tagNames);
|
||||
await indexProject(project.id);
|
||||
const full = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
return reply.code(201).send(serializeProject(full!));
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const existing = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||
const body = projectBody.partial().extend({ title: z.string().min(1).optional() }).parse(req.body);
|
||||
|
||||
let slug = existing.slug;
|
||||
if (body.slug) slug = body.slug;
|
||||
else if (body.title && body.title !== existing.title) {
|
||||
slug = await uniqueProjectSlug(body.title, existing.id);
|
||||
}
|
||||
|
||||
const visibility = (body.visibility as Visibility | undefined) ?? existing.visibility;
|
||||
await prisma.project.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
slug,
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.shortDescription !== undefined
|
||||
? { shortDescription: body.shortDescription }
|
||||
: {}),
|
||||
...(body.date !== undefined
|
||||
? { date: body.date ? new Date(body.date) : null }
|
||||
: {}),
|
||||
...(body.software !== undefined ? { software: body.software } : {}),
|
||||
...(body.externalLinks !== undefined ? { externalLinks: body.externalLinks } : {}),
|
||||
...(body.featured !== undefined ? { featured: body.featured } : {}),
|
||||
...(body.displayPriority !== undefined
|
||||
? { displayPriority: body.displayPriority }
|
||||
: {}),
|
||||
visibility,
|
||||
...(body.thumbnailId !== undefined ? { thumbnailId: body.thumbnailId } : {}),
|
||||
...(visibility === "published" && !existing.publishedAt
|
||||
? { publishedAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (body.categoryIds) await syncCategories(existing.id, body.categoryIds);
|
||||
if (body.tagNames) await syncTags(existing.id, body.tagNames);
|
||||
await indexProject(existing.id);
|
||||
const full = await prisma.project.findUnique({
|
||||
where: { id: existing.id },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
return serializeProject(full!);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const existing = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||
await prisma.project.delete({ where: { id: existing.id } });
|
||||
await removeProjectFromIndex(existing.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/projects/reorder", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z.object({ ids: z.array(z.string()) }).parse(req.body);
|
||||
await prisma.$transaction(
|
||||
body.ids.map((id, i) =>
|
||||
prisma.project.update({ where: { id }, data: { displayPriority: i } })
|
||||
)
|
||||
);
|
||||
for (const id of body.ids) await indexProject(id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Media upload
|
||||
app.post("/api/v1/admin/media/upload", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const file = await req.file();
|
||||
if (!file) return reply.code(400).send({ error: "No file" });
|
||||
const buffer = await file.toBuffer();
|
||||
const projectId = (file.fields as Record<string, { value?: string }>)?.projectId?.value;
|
||||
const isVideo = file.mimetype.startsWith("video/");
|
||||
|
||||
if (isVideo) {
|
||||
const saved = await saveVideoUpload(buffer, file.mimetype, file.filename);
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
projectId: projectId || null,
|
||||
type: "video",
|
||||
url: saved.url,
|
||||
filename: saved.filename,
|
||||
mimeType: saved.mimeType,
|
||||
sizeBytes: saved.sizeBytes,
|
||||
videoSource: "self_hosted",
|
||||
},
|
||||
});
|
||||
return reply.code(201).send(media);
|
||||
}
|
||||
|
||||
if (!file.mimetype.startsWith("image/")) {
|
||||
return reply.code(400).send({ error: "Unsupported file type" });
|
||||
}
|
||||
const saved = await saveImageUpload(buffer, file.mimetype, file.filename);
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
projectId: projectId || null,
|
||||
type: "image",
|
||||
url: saved.url,
|
||||
thumbnailUrl: saved.thumbnailUrl,
|
||||
filename: saved.filename,
|
||||
mimeType: saved.mimeType,
|
||||
width: saved.width,
|
||||
height: saved.height,
|
||||
sizeBytes: saved.sizeBytes,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send(media);
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/media/video-link", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
projectId: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
const parsed = parseVideoUrl(body.url);
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
projectId: body.projectId || null,
|
||||
type: "video",
|
||||
url: parsed.embedUrl || body.url,
|
||||
externalUrl: parsed.externalUrl,
|
||||
videoSource: parsed.videoSource,
|
||||
videoId: parsed.videoId,
|
||||
caption: body.caption,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send(media);
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/v1/admin/media/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z
|
||||
.object({
|
||||
projectId: z.string().optional().nullable(),
|
||||
alt: z.string().optional().nullable(),
|
||||
caption: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
const media = await prisma.media.update({
|
||||
where: { id: req.params.id },
|
||||
data: body,
|
||||
});
|
||||
return media;
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/v1/admin/media/:id", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
await prisma.media.delete({ where: { id: req.params.id } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/v1/admin/projects/:id/thumbnail",
|
||||
async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const body = z.object({ mediaId: z.string() }).parse(req.body);
|
||||
const media = await prisma.media.findUnique({ where: { id: body.mediaId } });
|
||||
if (!media) return reply.code(404).send({ error: "Media not found" });
|
||||
await prisma.media.update({
|
||||
where: { id: media.id },
|
||||
data: { projectId: req.params.id },
|
||||
});
|
||||
const project = await prisma.project.update({
|
||||
where: { id: req.params.id },
|
||||
data: { thumbnailId: media.id },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
await indexProject(project.id);
|
||||
return serializeProject(project);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { getTypesense } from "../lib/typesense.js";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get("/api/v1/health", async () => {
|
||||
let db = "ok";
|
||||
let typesense = "ok";
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
db = "error";
|
||||
}
|
||||
try {
|
||||
await getTypesense().health.retrieve();
|
||||
} catch {
|
||||
typesense = "error";
|
||||
}
|
||||
const status = db === "ok" ? "ok" : "degraded";
|
||||
return {
|
||||
status,
|
||||
service: "jmartgraphix-com",
|
||||
db,
|
||||
typesense,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { Visibility } from "@prisma/client";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { projectPublicInclude, serializeProject } from "../lib/project-include.js";
|
||||
import { searchProjects } from "../lib/typesense.js";
|
||||
|
||||
export async function publicRoutes(app: FastifyInstance) {
|
||||
app.get("/api/v1/site", async () => {
|
||||
const setting = await prisma.siteSetting.findUnique({ where: { key: "site" } });
|
||||
return setting?.value ?? {};
|
||||
});
|
||||
|
||||
app.get("/api/v1/categories", async () => {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: { project: { visibility: Visibility.published } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description,
|
||||
sortOrder: c.sortOrder,
|
||||
projectCount: c._count.projects,
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/api/v1/tags", async () => {
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: { project: { visibility: Visibility.published } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return tags.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
projectCount: t._count.projects,
|
||||
}));
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
q?: string;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
featured?: string;
|
||||
sort?: string;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
view?: string;
|
||||
};
|
||||
}>("/api/v1/projects", async (req) => {
|
||||
const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1);
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage || "24", 10) || 24));
|
||||
const sort = req.query.sort || "priority";
|
||||
const category = req.query.category;
|
||||
const tag = req.query.tag;
|
||||
const featured =
|
||||
req.query.featured === "true" ? true : req.query.featured === "false" ? false : undefined;
|
||||
const q = req.query.q;
|
||||
const viewSlug = req.query.view;
|
||||
|
||||
// Portfolio view prioritization
|
||||
if (viewSlug && !category) {
|
||||
const view = await prisma.portfolioView.findFirst({
|
||||
where: { slug: viewSlug, isActive: true },
|
||||
include: {
|
||||
categoryPriorities: {
|
||||
orderBy: { priority: "asc" },
|
||||
include: { category: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (view) {
|
||||
const orderedCatIds = view.categoryPriorities.map((cp) => cp.categoryId);
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
visibility: Visibility.published,
|
||||
...(featured !== undefined ? { featured } : {}),
|
||||
...(tag
|
||||
? { tags: { some: { tag: { OR: [{ slug: tag }, { name: tag }] } } } }
|
||||
: {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: projectPublicInclude,
|
||||
orderBy: [{ displayPriority: "asc" }, { date: "desc" }, { title: "asc" }],
|
||||
});
|
||||
|
||||
const score = (p: (typeof projects)[0]) => {
|
||||
const catIds = p.categories.map((c) => c.categoryId);
|
||||
let best = 999;
|
||||
for (const cid of catIds) {
|
||||
const idx = orderedCatIds.indexOf(cid);
|
||||
if (idx >= 0 && idx < best) best = idx;
|
||||
}
|
||||
if (best === 999 && !view.showOthers) return null;
|
||||
return best;
|
||||
};
|
||||
|
||||
const scored = projects
|
||||
.map((p) => ({ p, s: score(p) }))
|
||||
.filter((x): x is { p: (typeof projects)[0]; s: number } => x.s !== null)
|
||||
.sort((a, b) => {
|
||||
if (a.s !== b.s) return a.s - b.s;
|
||||
if (a.p.displayPriority !== b.p.displayPriority)
|
||||
return a.p.displayPriority - b.p.displayPriority;
|
||||
const da = a.p.date?.getTime() ?? 0;
|
||||
const db = b.p.date?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
const total = scored.length;
|
||||
const slice = scored.slice((page - 1) * perPage, page * perPage);
|
||||
return {
|
||||
data: slice.map((x) => serializeProject(x.p)),
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
view: {
|
||||
slug: view.slug,
|
||||
name: view.name,
|
||||
description: view.description,
|
||||
categories: view.categoryPriorities.map((cp) => ({
|
||||
...cp.category,
|
||||
priority: cp.priority,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Typesense search when query present
|
||||
if (q?.trim()) {
|
||||
try {
|
||||
const result = await searchProjects({
|
||||
q,
|
||||
category,
|
||||
tag,
|
||||
featured,
|
||||
visibility: "published",
|
||||
page,
|
||||
perPage,
|
||||
sortBy: sort,
|
||||
});
|
||||
const ids = (result.hits ?? []).map((h) => (h.document as { id: string }).id);
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: ids }, visibility: Visibility.published },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
const byId = Object.fromEntries(projects.map((p) => [p.id, p]));
|
||||
const ordered = ids.map((id) => byId[id]).filter(Boolean);
|
||||
return {
|
||||
data: ordered.map(serializeProject),
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total: result.found ?? ordered.length,
|
||||
totalPages: Math.ceil((result.found ?? ordered.length) / perPage),
|
||||
search: true,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("Typesense search fallback to SQL:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const where = {
|
||||
visibility: Visibility.published,
|
||||
...(featured !== undefined ? { featured } : {}),
|
||||
...(category
|
||||
? {
|
||||
categories: {
|
||||
some: { category: { OR: [{ slug: category }, { name: category }] } },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(tag
|
||||
? { tags: { some: { tag: { OR: [{ slug: tag }, { name: tag }] } } } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let orderBy:
|
||||
| { displayPriority: "asc" | "desc" }[]
|
||||
| { date: "asc" | "desc" }[]
|
||||
| { title: "asc" | "desc" }[]
|
||||
| object[] = [{ displayPriority: "asc" }, { date: "desc" }];
|
||||
if (sort === "date") orderBy = [{ date: "desc" }, { displayPriority: "asc" }];
|
||||
else if (sort === "title" || sort === "alphabetical")
|
||||
orderBy = [{ title: "asc" }, { displayPriority: "asc" }];
|
||||
else if (sort === "priority") orderBy = [{ displayPriority: "asc" }, { date: "desc" }];
|
||||
|
||||
const [total, projects] = await Promise.all([
|
||||
prisma.project.count({ where }),
|
||||
prisma.project.findMany({
|
||||
where,
|
||||
include: projectPublicInclude,
|
||||
orderBy: orderBy as never,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: projects.map(serializeProject),
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { slug: string } }>("/api/v1/projects/:slug", async (req, reply) => {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { slug: req.params.slug, visibility: Visibility.published },
|
||||
include: projectPublicInclude,
|
||||
});
|
||||
if (!project) {
|
||||
return reply.code(404).send({ error: "Not found" });
|
||||
}
|
||||
return serializeProject(project);
|
||||
});
|
||||
|
||||
app.get("/api/v1/featured", async () => {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { visibility: Visibility.published, featured: true },
|
||||
include: projectPublicInclude,
|
||||
orderBy: [{ displayPriority: "asc" }, { date: "desc" }],
|
||||
take: 12,
|
||||
});
|
||||
return projects.map(serializeProject);
|
||||
});
|
||||
|
||||
app.get("/api/v1/views", async () => {
|
||||
return prisma.portfolioView.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
categoryPriorities: {
|
||||
orderBy: { priority: "asc" },
|
||||
include: { category: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { slug: string } }>("/api/v1/views/:slug", async (req, reply) => {
|
||||
const view = await prisma.portfolioView.findFirst({
|
||||
where: { slug: req.params.slug, isActive: true },
|
||||
include: {
|
||||
categoryPriorities: {
|
||||
orderBy: { priority: "asc" },
|
||||
include: { category: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!view) return reply.code(404).send({ error: "Not found" });
|
||||
return view;
|
||||
});
|
||||
|
||||
app.get("/api/v1/resume", async (_req, reply) => {
|
||||
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||
if (!resume) return reply.code(404).send({ error: "No resume published" });
|
||||
return resume;
|
||||
});
|
||||
|
||||
// SEO: sitemap
|
||||
app.get("/sitemap.xml", async (_req, reply) => {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { visibility: Visibility.published },
|
||||
select: { slug: true, updatedAt: true },
|
||||
});
|
||||
const base = process.env.PUBLIC_URL?.replace(/\/$/, "") || "https://jmartgraphix.com";
|
||||
const urls = [
|
||||
"",
|
||||
"/portfolio",
|
||||
"/resume",
|
||||
"/about",
|
||||
...projects.map((p) => `/project/${p.slug}`),
|
||||
];
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls
|
||||
.map(
|
||||
(u) => ` <url><loc>${base}${u}</loc><changefreq>weekly</changefreq></url>`
|
||||
)
|
||||
.join("\n")}
|
||||
</urlset>`;
|
||||
return reply.type("application/xml").send(xml);
|
||||
});
|
||||
|
||||
app.get("/rss.xml", async (_req, reply) => {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { visibility: Visibility.published },
|
||||
orderBy: { date: "desc" },
|
||||
take: 30,
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
shortDescription: true,
|
||||
description: true,
|
||||
date: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
const base = process.env.PUBLIC_URL?.replace(/\/$/, "") || "https://jmartgraphix.com";
|
||||
const items = projects
|
||||
.map(
|
||||
(p) => ` <item>
|
||||
<title><![CDATA[${p.title}]]></title>
|
||||
<link>${base}/project/${p.slug}</link>
|
||||
<guid>${base}/project/${p.slug}</guid>
|
||||
<description><![CDATA[${p.shortDescription || p.description.slice(0, 300)}]]></description>
|
||||
<pubDate>${(p.date || p.updatedAt).toUTCString()}</pubDate>
|
||||
</item>`
|
||||
)
|
||||
.join("\n");
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>jmartgraphix Portfolio</title>
|
||||
<link>${base}</link>
|
||||
<description>Creative professional portfolio updates</description>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
return reply.type("application/rss+xml").send(xml);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function renderResumeHtml(resume: any): string {
|
||||
if (resume.htmlContent) {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(
|
||||
resume.fullName
|
||||
)} — Resume</title>
|
||||
<style>
|
||||
body{font-family:Georgia,serif;max-width:800px;margin:40px auto;padding:0 24px;color:#111;line-height:1.5}
|
||||
@media print{body{margin:0}}
|
||||
</style></head><body>${resume.htmlContent}</body></html>`;
|
||||
}
|
||||
|
||||
const sections = Array.isArray(resume.sections) ? resume.sections : [];
|
||||
let sectionsHtml = "";
|
||||
for (const sec of sections) {
|
||||
sectionsHtml += `<section class="sec"><h2>${escapeHtml(sec.title || sec.type || "")}</h2>`;
|
||||
if (sec.type === "skills" && Array.isArray(sec.items)) {
|
||||
for (const g of sec.items) {
|
||||
sectionsHtml += `<p><strong>${escapeHtml(g.group || "")}</strong> — ${(g.skills || [])
|
||||
.map((s: string) => escapeHtml(s))
|
||||
.join(", ")}</p>`;
|
||||
}
|
||||
} else if (sec.type === "links" && Array.isArray(sec.items)) {
|
||||
sectionsHtml += `<ul>${sec.items
|
||||
.map(
|
||||
(i: { label: string; url: string }) =>
|
||||
`<li><a href="${escapeHtml(i.url)}">${escapeHtml(i.label)}</a></li>`
|
||||
)
|
||||
.join("")}</ul>`;
|
||||
} else if (Array.isArray(sec.items)) {
|
||||
for (const item of sec.items) {
|
||||
sectionsHtml += `<div class="item">
|
||||
<h3>${escapeHtml(item.title || "")}${
|
||||
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
||||
}</h3>
|
||||
<div class="meta">${[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")]
|
||||
.filter(Boolean)
|
||||
.map(escapeHtml)
|
||||
.join(" · ")}</div>
|
||||
${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}
|
||||
${
|
||||
item.highlights
|
||||
? `<ul>${item.highlights.map((h: string) => `<li>${escapeHtml(h)}</li>`).join("")}</ul>`
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
sectionsHtml += `</section>`;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:"Segoe UI",system-ui,sans-serif;max-width:820px;margin:0 auto;padding:48px 32px;color:#0f0f0f;line-height:1.55;background:#fff}
|
||||
h1{font-size:2rem;margin:0 0 4px;letter-spacing:-0.02em}
|
||||
.title{color:#444;font-size:1.1rem;margin-bottom:8px}
|
||||
.contact{color:#555;font-size:0.9rem;margin-bottom:24px}
|
||||
.summary{font-size:1rem;margin-bottom:28px;border-left:3px solid #111;padding-left:16px}
|
||||
h2{font-size:0.85rem;text-transform:uppercase;letter-spacing:0.12em;border-bottom:1px solid #ddd;padding-bottom:6px;margin:28px 0 14px;color:#222}
|
||||
h3{font-size:1.05rem;margin:0 0 4px}
|
||||
.meta{color:#666;font-size:0.85rem;margin-bottom:8px}
|
||||
.item{margin-bottom:18px}
|
||||
ul{margin:6px 0 0 18px;padding:0}
|
||||
li{margin-bottom:4px}
|
||||
a{color:#111}
|
||||
@media print{body{padding:24px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>${escapeHtml(resume.fullName)}</h1>
|
||||
${resume.title ? `<div class="title">${escapeHtml(resume.title)}</div>` : ""}
|
||||
<div class="contact">
|
||||
${[
|
||||
resume.email,
|
||||
resume.phone,
|
||||
resume.location,
|
||||
resume.website,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(escapeHtml)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</header>
|
||||
${resume.summary ? `<p class="summary">${escapeHtml(resume.summary)}</p>` : ""}
|
||||
${sectionsHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function resumePdfRoutes(app: FastifyInstance) {
|
||||
app.get("/api/v1/resume/html", async (_req, reply) => {
|
||||
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||
if (!resume) return reply.code(404).send({ error: "No resume" });
|
||||
return reply.type("text/html").send(renderResumeHtml(resume));
|
||||
});
|
||||
|
||||
app.get("/api/v1/resume/pdf", async (_req, reply) => {
|
||||
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||
if (!resume) return reply.code(404).send({ error: "No resume" });
|
||||
|
||||
const html = renderResumeHtml(resume);
|
||||
try {
|
||||
// Dynamic import so local dev without chromium still works for HTML
|
||||
const puppeteer = await import("puppeteer");
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "16mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
const filename = `${(resume.fullName || "resume").replace(/\s+/g, "_")}_Resume.pdf`;
|
||||
return reply
|
||||
.header("Content-Type", "application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(Buffer.from(pdf));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("PDF generation failed:", err);
|
||||
// Fallback: return HTML with print hint
|
||||
return reply
|
||||
.code(503)
|
||||
.type("application/json")
|
||||
.send({
|
||||
error: "PDF generation unavailable",
|
||||
htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
|
||||
message: "Open the HTML version and use Print → Save as PDF",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { renderResumeHtml };
|
||||
Reference in New Issue
Block a user