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
@@ -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());