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:
2026-07-23 17:01:34 -04:00
commit 43928f7a49
61 changed files with 13218 additions and 0 deletions
+58
View File
@@ -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")),
};
+176
View File
@@ -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);
});
+61
View File
@@ -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;
}
+168
View File
@@ -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,
};
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
+21
View File
@@ -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 ?? [],
};
}
+39
View File
@@ -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++}`;
}
}
+175
View File
@@ -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;
}
+282
View File
@@ -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 };
});
}
+316
View File
@@ -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);
}
);
}
+28
View File
@@ -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(),
};
});
}
+356
View File
@@ -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);
});
}
+159
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 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 };