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
+43
View File
@@ -0,0 +1,43 @@
{
"name": "server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && cp -r src/openapi dist/openapi 2>/dev/null || true",
"start": "node dist/index.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy && prisma db seed",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:push": "prisma db push",
"import:artstation": "tsx scripts/import-artstation.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@fastify/cors": "^10.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.0",
"@fastify/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.2",
"@prisma/client": "^6.5.0",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^4.0.2",
"puppeteer": "^24.4.0",
"sharp": "^0.33.5",
"slugify": "^1.6.6",
"typesense": "^2.0.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"prisma": "^6.5.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
@@ -0,0 +1,168 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('published', 'draft', 'private');
-- CreateEnum
CREATE TYPE "MediaType" AS ENUM ('image', 'video', 'external');
-- CreateEnum
CREATE TYPE "VideoSource" AS ENUM ('youtube', 'vimeo', 'self_hosted', 'external');
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tags" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"short_description" TEXT,
"date" DATE,
"software" TEXT[] DEFAULT ARRAY[]::TEXT[],
"external_links" JSONB NOT NULL DEFAULT '[]',
"featured" BOOLEAN NOT NULL DEFAULT false,
"display_priority" INTEGER NOT NULL DEFAULT 0,
"visibility" "Visibility" NOT NULL DEFAULT 'draft',
"thumbnail_id" TEXT,
"source_url" TEXT,
"source_platform" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"published_at" TIMESTAMP(3),
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "project_categories" (
"project_id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
CONSTRAINT "project_categories_pkey" PRIMARY KEY ("project_id","category_id")
);
-- CreateTable
CREATE TABLE "project_tags" (
"project_id" TEXT NOT NULL,
"tag_id" TEXT NOT NULL,
CONSTRAINT "project_tags_pkey" PRIMARY KEY ("project_id","tag_id")
);
-- CreateTable
CREATE TABLE "media" (
"id" TEXT NOT NULL,
"project_id" TEXT,
"type" "MediaType" NOT NULL DEFAULT 'image',
"url" TEXT NOT NULL,
"thumbnail_url" TEXT,
"filename" TEXT,
"mime_type" TEXT,
"width" INTEGER,
"height" INTEGER,
"size_bytes" INTEGER,
"alt" TEXT,
"caption" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"video_source" "VideoSource",
"video_id" TEXT,
"external_url" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "media_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "portfolio_views" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"default_sort" TEXT NOT NULL DEFAULT 'priority',
"show_others" BOOLEAN NOT NULL DEFAULT true,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "portfolio_views_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "portfolio_view_categories" (
"view_id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "portfolio_view_categories_pkey" PRIMARY KEY ("view_id","category_id")
);
-- CreateTable
CREATE TABLE "resumes" (
"id" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"full_name" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '',
"email" TEXT,
"phone" TEXT,
"location" TEXT,
"website" TEXT,
"summary" TEXT NOT NULL DEFAULT '',
"sections" JSONB NOT NULL DEFAULT '[]',
"html_content" TEXT,
"theme" TEXT NOT NULL DEFAULT 'dark',
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "resumes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "site_settings" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "site_settings_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug");
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
CREATE UNIQUE INDEX "projects_slug_key" ON "projects"("slug");
CREATE INDEX "projects_visibility_display_priority_idx" ON "projects"("visibility", "display_priority");
CREATE INDEX "projects_featured_idx" ON "projects"("featured");
CREATE INDEX "projects_date_idx" ON "projects"("date");
CREATE INDEX "media_project_id_sort_order_idx" ON "media"("project_id", "sort_order");
CREATE UNIQUE INDEX "portfolio_views_slug_key" ON "portfolio_views"("slug");
-- AddForeignKeys
ALTER TABLE "project_categories" ADD CONSTRAINT "project_categories_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "project_categories" ADD CONSTRAINT "project_categories_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "media" ADD CONSTRAINT "media_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "projects" ADD CONSTRAINT "projects_thumbnail_id_fkey" FOREIGN KEY ("thumbnail_id") REFERENCES "media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "portfolio_view_categories" ADD CONSTRAINT "portfolio_view_categories_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "portfolio_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "portfolio_view_categories" ADD CONSTRAINT "portfolio_view_categories_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+192
View File
@@ -0,0 +1,192 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Visibility {
published
draft
private
}
enum MediaType {
image
video
external
}
enum VideoSource {
youtube
vimeo
self_hosted
external
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
projects ProjectCategory[]
viewPriorities PortfolioViewCategory[]
@@map("categories")
}
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
createdAt DateTime @default(now()) @map("created_at")
projects ProjectTag[]
@@map("tags")
}
model Project {
id String @id @default(cuid())
title String
slug String @unique
description String @default("")
shortDescription String? @map("short_description")
date DateTime? @db.Date
software String[] @default([])
externalLinks Json @default("[]") @map("external_links")
featured Boolean @default(false)
displayPriority Int @default(0) @map("display_priority")
visibility Visibility @default(draft)
thumbnailId String? @map("thumbnail_id")
sourceUrl String? @map("source_url")
sourcePlatform String? @map("source_platform")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
publishedAt DateTime? @map("published_at")
thumbnail Media? @relation("ProjectThumbnail", fields: [thumbnailId], references: [id], onDelete: SetNull)
media Media[] @relation("ProjectMedia")
categories ProjectCategory[]
tags ProjectTag[]
@@index([visibility, displayPriority])
@@index([featured])
@@index([date])
@@map("projects")
}
model ProjectCategory {
projectId String @map("project_id")
categoryId String @map("category_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([projectId, categoryId])
@@map("project_categories")
}
model ProjectTag {
projectId String @map("project_id")
tagId String @map("tag_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([projectId, tagId])
@@map("project_tags")
}
model Media {
id String @id @default(cuid())
projectId String? @map("project_id")
type MediaType @default(image)
url String
thumbnailUrl String? @map("thumbnail_url")
filename String?
mimeType String? @map("mime_type")
width Int?
height Int?
sizeBytes Int? @map("size_bytes")
alt String?
caption String?
sortOrder Int @default(0) @map("sort_order")
videoSource VideoSource? @map("video_source")
videoId String? @map("video_id")
externalUrl String? @map("external_url")
createdAt DateTime @default(now()) @map("created_at")
project Project? @relation("ProjectMedia", fields: [projectId], references: [id], onDelete: Cascade)
thumbnailFor Project[] @relation("ProjectThumbnail")
@@index([projectId, sortOrder])
@@map("media")
}
/// Named portfolio landing experiences e.g. game-dev, engineering
model PortfolioView {
id String @id @default(cuid())
name String
slug String @unique
description String?
/// Default sort when no category match: priority | date | title
defaultSort String @default("priority") @map("default_sort")
/// Show projects outside configured categories after prioritized ones
showOthers Boolean @default(true) @map("show_others")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
categoryPriorities PortfolioViewCategory[]
@@map("portfolio_views")
}
model PortfolioViewCategory {
viewId String @map("view_id")
categoryId String @map("category_id")
priority Int @default(0)
view PortfolioView @relation(fields: [viewId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([viewId, categoryId])
@@map("portfolio_view_categories")
}
model Resume {
id String @id @default(cuid())
/// Single active resume — only one should be isActive=true
isActive Boolean @default(true) @map("is_active")
fullName String @map("full_name")
title String @default("")
email String?
phone String?
location String?
website String?
summary String @default("")
/// Structured sections as JSON: experience, education, skills, etc.
sections Json @default("[]")
/// Optional raw HTML override for public render
htmlContent String? @map("html_content") @db.Text
theme String @default("dark")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("resumes")
}
model SiteSetting {
key String @id
value Json
updatedAt DateTime @updatedAt @map("updated_at")
@@map("site_settings")
}
+213
View File
@@ -0,0 +1,213 @@
import { PrismaClient } from "@prisma/client";
import slugify from "slugify";
const prisma = new PrismaClient();
const CATEGORIES = [
"3D Modeling",
"3D Animation",
"CAD",
"3D Sculpting",
"AI Generated Content",
"Video",
"Photography",
"Graphic Design",
"Illustration",
];
function slug(s: string) {
return slugify(s, { lower: true, strict: true });
}
async function main() {
console.log("Seeding categories…");
for (let i = 0; i < CATEGORIES.length; i++) {
const name = CATEGORIES[i];
await prisma.category.upsert({
where: { slug: slug(name) },
create: { name, slug: slug(name), sortOrder: i },
update: { name, sortOrder: i },
});
}
// Convenience slugs for URL filters (/portfolio/3d, /portfolio/ai, etc.)
const aliases: Record<string, string> = {
"3d-modeling": "3d",
"3d-animation": "animation",
"ai-generated-content": "ai",
"graphic-design": "design",
};
// We keep canonical slugs; PortfolioView can use short names
console.log("Seeding default portfolio views…");
const cats = await prisma.category.findMany();
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c]));
async function upsertView(
name: string,
viewSlug: string,
description: string,
orderedSlugs: string[]
) {
const view = await prisma.portfolioView.upsert({
where: { slug: viewSlug },
create: { name, slug: viewSlug, description, showOthers: true },
update: { name, description },
});
await prisma.portfolioViewCategory.deleteMany({ where: { viewId: view.id } });
for (let i = 0; i < orderedSlugs.length; i++) {
const cat = bySlug[orderedSlugs[i]];
if (!cat) continue;
await prisma.portfolioViewCategory.create({
data: { viewId: view.id, categoryId: cat.id, priority: i },
});
}
}
await upsertView(
"Game Development",
"game-dev",
"Work prioritized for game studios and interactive media.",
["3d-animation", "3d-modeling", "3d-sculpting", "ai-generated-content", "cad"]
);
await upsertView(
"Engineering",
"engineering",
"Work prioritized for engineering and product teams.",
["cad", "3d-modeling", "illustration", "graphic-design"]
);
await upsertView(
"3D",
"3d",
"All 3D work front and center.",
["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
);
await upsertView("Animation", "animation", "Animation-focused portfolio.", [
"3d-animation",
"video",
]);
await upsertView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
await upsertView("AI", "ai", "AI-generated creative work.", [
"ai-generated-content",
"illustration",
"graphic-design",
]);
// Shorter category filter aliases as views that just prioritize that category
for (const [canonical, short] of Object.entries(aliases)) {
if (short === "3d" || short === "animation" || short === "ai") continue;
await upsertView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [
canonical,
]);
}
const existingResume = await prisma.resume.findFirst({ where: { isActive: true } });
if (!existingResume) {
console.log("Seeding sample resume…");
await prisma.resume.create({
data: {
isActive: true,
fullName: "J. Martin",
title: "3D Artist · Designer · Creative Technologist",
email: "contact@jmartgraphix.com",
website: "https://jmartgraphix.com",
location: "United States",
summary:
"Creative professional specializing in 3D modeling, CAD, animation, and visual design. Building polished digital experiences and production-ready assets for games, product visualization, and media.",
sections: [
{
type: "experience",
title: "Experience",
items: [
{
title: "Independent Creative / jmartgraphix",
organization: "Freelance",
location: "Remote",
startDate: "2018",
endDate: "Present",
description:
"Delivered 3D models, animations, CAD designs, and graphic work for clients across game development, product design, and marketing.",
highlights: [
"End-to-end 3D pipeline: modeling, sculpting, texturing, rendering",
"CAD and product visualization for engineering collaborators",
"Motion graphics and short-form video production",
],
},
],
},
{
type: "skills",
title: "Software & Tools",
items: [
{
group: "3D & CAD",
skills: [
"Blender",
"Maya",
"ZBrush",
"SolidWorks",
"Fusion 360",
"Substance Painter",
],
},
{
group: "Design & Video",
skills: [
"Photoshop",
"Illustrator",
"After Effects",
"Premiere Pro",
"DaVinci Resolve",
],
},
{
group: "Other",
skills: ["AI image/video tools", "Git", "Web (HTML/CSS)"],
},
],
},
{
type: "links",
title: "Links",
items: [
{ label: "Portfolio", url: "https://jmartgraphix.com" },
{ label: "ArtStation", url: "https://jmartgraphix.artstation.com/" },
{ label: "YouTube", url: "https://www.youtube.com/@samuraijkm" },
{ label: "Vimeo", url: "https://vimeo.com/jmartgraphix" },
],
},
],
},
});
}
await prisma.siteSetting.upsert({
where: { key: "site" },
create: {
key: "site",
value: {
name: "jmartgraphix",
tagline: "Creative Professional Portfolio",
about:
"I create 3D art, technical design, animation, and visual media — from concept through polished delivery.",
social: {
artstation: "https://jmartgraphix.artstation.com/",
youtube: "https://www.youtube.com/@samuraijkm",
vimeo: "https://vimeo.com/jmartgraphix",
},
heroTitle: "jmartgraphix",
heroSubtitle: "3D · Design · Motion · Craft",
},
},
update: {},
});
console.log("Seed complete.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+298
View File
@@ -0,0 +1,298 @@
/**
* ArtStation project importer for jmartgraphix portfolio.
*
* Usage:
* npm run import:artstation -- --user jmartgraphix
* npm run import:artstation -- --user jmartgraphix --dry-run
* npm run import:artstation -- --url https://www.artstation.com/artwork/XXXX
*
* Fetches public ArtStation JSON endpoints, downloads images, and creates
* draft projects so you can edit/publish from the admin panel afterward.
*/
import "dotenv/config";
import { PrismaClient, Visibility } from "@prisma/client";
import slugify from "slugify";
import { downloadRemoteImage } from "../src/lib/media.js";
const prisma = new PrismaClient();
const USER_AGENT =
"Mozilla/5.0 (compatible; jmartgraphix-importer/1.0; +https://jmartgraphix.com)";
function arg(name: string, fallback?: string): string | undefined {
const idx = process.argv.indexOf(`--${name}`);
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
return fallback;
}
function hasFlag(name: string): boolean {
return process.argv.includes(`--${name}`);
}
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json() as Promise<T>;
}
interface AsProjectListItem {
id: number;
title: string;
hash_id?: string;
permalink?: string;
cover?: { url?: string; small_square_url?: string };
mediums?: { name: string }[];
categories?: { name: string }[];
published_at?: string;
}
interface AsProjectDetail {
id: number;
title: string;
description?: string;
hash_id?: string;
permalink?: string;
published_at?: string;
tags?: string[];
mediums?: { name: string }[];
categories?: { name: string }[];
software_items?: { name: string }[];
assets?: {
id: number;
title?: string;
asset_type?: string;
image_url?: string;
has_image?: boolean;
width?: number;
height?: number;
}[];
cover?: { url?: string };
user?: { full_name?: string; username?: string };
}
// Map ArtStation medium/category names to our category slugs
const CATEGORY_MAP: Record<string, string> = {
"3d": "3d-modeling",
"3d modeling": "3d-modeling",
modeling: "3d-modeling",
characters: "3d-modeling",
environments: "3d-modeling",
animation: "3d-animation",
"3d animation": "3d-animation",
cad: "cad",
product: "cad",
industrial: "cad",
sculpting: "3d-sculpting",
"digital sculpting": "3d-sculpting",
ai: "ai-generated-content",
"ai art": "ai-generated-content",
video: "video",
cinematography: "video",
photography: "photography",
"graphic design": "graphic-design",
design: "graphic-design",
illustration: "illustration",
concept: "illustration",
};
async function resolveCategoryIds(names: string[]): Promise<string[]> {
const all = await prisma.category.findMany();
const bySlug = Object.fromEntries(all.map((c) => [c.slug, c.id]));
const ids = new Set<string>();
for (const n of names) {
const key = n.toLowerCase().trim();
const mapped = CATEGORY_MAP[key];
if (mapped && bySlug[mapped]) ids.add(bySlug[mapped]);
const direct = all.find((c) => c.name.toLowerCase() === key || c.slug === slugify(key, { lower: true, strict: true }));
if (direct) ids.add(direct.id);
}
return [...ids];
}
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title, { lower: true, strict: true }) || "artwork";
let slug = base;
let n = 2;
while (await prisma.project.findUnique({ where: { slug } })) {
slug = `${base}-${n++}`;
}
return slug;
}
async function importProject(hashOrUrl: string, dryRun: boolean): Promise<void> {
let hash = hashOrUrl;
const m = hashOrUrl.match(/artstation\.com\/artwork\/([A-Za-z0-9]+)/);
if (m) hash = m[1];
const detail = await fetchJson<AsProjectDetail>(
`https://www.artstation.com/projects/${hash}.json`
);
const sourceUrl =
detail.permalink || `https://www.artstation.com/artwork/${detail.hash_id || hash}`;
const existing = await prisma.project.findFirst({
where: { sourceUrl },
});
if (existing) {
console.log(` skip (exists): ${detail.title}`);
return;
}
const mediums = (detail.mediums || []).map((m) => m.name);
const cats = (detail.categories || []).map((c) => c.name);
const software = (detail.software_items || []).map((s) => s.name);
const tags = detail.tags || [];
const categoryIds = await resolveCategoryIds([...mediums, ...cats]);
console.log(` import: ${detail.title} (${(detail.assets || []).length} assets)`);
if (dryRun) return;
const slug = await uniqueSlug(detail.title);
const project = await prisma.project.create({
data: {
title: detail.title,
slug,
description: stripHtml(detail.description || ""),
shortDescription: stripHtml(detail.description || "").slice(0, 280) || null,
date: detail.published_at ? new Date(detail.published_at) : null,
software,
externalLinks: [{ label: "ArtStation", url: sourceUrl }],
featured: false,
displayPriority: 100,
visibility: Visibility.draft,
sourceUrl,
sourcePlatform: "artstation",
},
});
for (const cid of categoryIds) {
await prisma.projectCategory.create({
data: { projectId: project.id, categoryId: cid },
});
}
for (const t of tags) {
const tslug = slugify(t, { lower: true, strict: true });
if (!tslug) continue;
const tag = await prisma.tag.upsert({
where: { slug: tslug },
create: { name: t, slug: tslug },
update: {},
});
await prisma.projectTag.upsert({
where: { projectId_tagId: { projectId: project.id, tagId: tag.id } },
create: { projectId: project.id, tagId: tag.id },
update: {},
});
}
let sortOrder = 0;
let thumbnailId: string | null = null;
const assets = detail.assets || [];
for (const asset of assets) {
if (asset.asset_type && asset.asset_type !== "image" && !asset.has_image) continue;
const imageUrl = asset.image_url || detail.cover?.url;
if (!imageUrl) continue;
const saved = await downloadRemoteImage(imageUrl);
if (!saved) {
console.warn(` failed download: ${imageUrl}`);
continue;
}
const media = await prisma.media.create({
data: {
projectId: project.id,
type: "image",
url: saved.url,
thumbnailUrl: saved.thumbnailUrl,
filename: saved.filename,
mimeType: saved.mimeType,
width: saved.width || asset.width,
height: saved.height || asset.height,
sizeBytes: saved.sizeBytes,
alt: asset.title || detail.title,
sortOrder: sortOrder++,
},
});
if (!thumbnailId) thumbnailId = media.id;
}
if (thumbnailId) {
await prisma.project.update({
where: { id: project.id },
data: { thumbnailId },
});
}
console.log(` created draft project ${project.slug}`);
}
function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, "\n\n")
.trim();
}
async function listUserProjects(username: string): Promise<AsProjectListItem[]> {
const all: AsProjectListItem[] = [];
let page = 1;
for (;;) {
// Public profile projects endpoint
const url = `https://www.artstation.com/users/${username}/projects.json?page=${page}`;
try {
const data = await fetchJson<{ data?: AsProjectListItem[] } | AsProjectListItem[]>(url);
const batch = Array.isArray(data) ? data : data.data || [];
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page++;
if (page > 20) break;
} catch (err) {
console.error(`Failed to list page ${page}:`, err);
break;
}
}
return all;
}
async function main() {
const dryRun = hasFlag("dry-run");
const user = arg("user", "jmartgraphix")!;
const singleUrl = arg("url");
console.log(`ArtStation import (user=${user}, dryRun=${dryRun})`);
if (singleUrl) {
await importProject(singleUrl, dryRun);
} else {
const list = await listUserProjects(user);
console.log(`Found ${list.length} projects`);
for (const item of list) {
const hash = item.hash_id || String(item.id);
try {
await importProject(hash, dryRun);
await new Promise((r) => setTimeout(r, 500)); // be polite
} catch (err) {
console.error(` error importing ${item.title}:`, err);
}
}
}
console.log("Done. Review drafts in the admin panel and publish when ready.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+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 };
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}