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
+247
View File
@@ -0,0 +1,247 @@
export type Visibility = "published" | "draft" | "private";
export interface Category {
id: string;
name: string;
slug: string;
description?: string | null;
sortOrder?: number;
projectCount?: number;
}
export interface Tag {
id: string;
name: string;
slug: string;
projectCount?: number;
}
export interface Media {
id: string;
projectId?: string | null;
type: "image" | "video" | "external";
url: string;
thumbnailUrl?: string | null;
filename?: string | null;
mimeType?: string | null;
width?: number | null;
height?: number | null;
alt?: string | null;
caption?: string | null;
sortOrder: number;
videoSource?: "youtube" | "vimeo" | "self_hosted" | "external" | null;
videoId?: string | null;
externalUrl?: string | null;
}
export interface ExternalLink {
label: string;
url: string;
}
export interface Project {
id: string;
title: string;
slug: string;
description: string;
shortDescription?: string | null;
date?: string | null;
software: string[];
externalLinks: ExternalLink[];
featured: boolean;
displayPriority: number;
visibility: Visibility;
thumbnailId?: string | null;
thumbnail?: Media | null;
media?: Media[];
categories: Category[];
tags: Tag[];
sourceUrl?: string | null;
sourcePlatform?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface PortfolioView {
id: string;
name: string;
slug: string;
description?: string | null;
defaultSort?: string;
showOthers?: boolean;
isActive?: boolean;
categoryPriorities?: {
priority: number;
category: Category;
categoryId?: string;
}[];
}
export interface Resume {
id: string;
fullName: string;
title: string;
email?: string | null;
phone?: string | null;
location?: string | null;
website?: string | null;
summary: string;
sections: unknown[];
htmlContent?: string | null;
theme?: string;
}
export interface SiteSettings {
name?: string;
tagline?: string;
about?: string;
social?: Record<string, string>;
heroTitle?: string;
heroSubtitle?: string;
}
export interface ListMeta {
page: number;
perPage: number;
total: number;
totalPages: number;
view?: {
slug: string;
name: string;
description?: string | null;
categories?: Category[];
};
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
...(init?.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...init?.headers,
},
});
if (!res.ok) {
let msg = res.statusText;
try {
const j = await res.json();
msg = j.message || j.error || msg;
} catch {
/* */
}
throw new Error(msg || `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
site: () => request<SiteSettings>("/api/v1/site"),
categories: () => request<Category[]>("/api/v1/categories"),
tags: () => request<Tag[]>("/api/v1/tags"),
projects: (params: Record<string, string | number | undefined> = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "") q.set(k, String(v));
});
return request<{ data: Project[]; meta: ListMeta }>(`/api/v1/projects?${q}`);
},
project: (slug: string) => request<Project>(`/api/v1/projects/${slug}`),
featured: () => request<Project[]>("/api/v1/featured"),
views: () => request<PortfolioView[]>("/api/v1/views"),
view: (slug: string) => request<PortfolioView>(`/api/v1/views/${slug}`),
resume: () => request<Resume>("/api/v1/resume"),
health: () => request<{ status: string }>("/api/v1/health"),
// Admin
me: () => request<{ username: string; name?: string; groups: string[] }>("/api/v1/admin/me"),
adminProjects: (params: Record<string, string | number | undefined> = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "") q.set(k, String(v));
});
return request<{ data: Project[]; meta: ListMeta }>(`/api/v1/admin/projects?${q}`);
},
adminProject: (id: string) => request<Project>(`/api/v1/admin/projects/${id}`),
createProject: (body: unknown) =>
request<Project>("/api/v1/admin/projects", { method: "POST", body: JSON.stringify(body) }),
updateProject: (id: string, body: unknown) =>
request<Project>(`/api/v1/admin/projects/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteProject: (id: string) =>
request<{ ok: boolean }>(`/api/v1/admin/projects/${id}`, { method: "DELETE" }),
reorderProjects: (ids: string[]) =>
request<{ ok: boolean }>("/api/v1/admin/projects/reorder", {
method: "POST",
body: JSON.stringify({ ids }),
}),
uploadMedia: (file: File, projectId?: string) => {
const fd = new FormData();
fd.append("file", file);
if (projectId) fd.append("projectId", projectId);
return request<Media>("/api/v1/admin/media/upload", { method: "POST", body: fd });
},
addVideoLink: (url: string, projectId?: string) =>
request<Media>("/api/v1/admin/media/video-link", {
method: "POST",
body: JSON.stringify({ url, projectId }),
}),
deleteMedia: (id: string) =>
request<{ ok: boolean }>(`/api/v1/admin/media/${id}`, { method: "DELETE" }),
setThumbnail: (projectId: string, mediaId: string) =>
request<Project>(`/api/v1/admin/projects/${projectId}/thumbnail`, {
method: "POST",
body: JSON.stringify({ mediaId }),
}),
adminCategories: () => request<Category[]>("/api/v1/admin/categories"),
createCategory: (body: unknown) =>
request<Category>("/api/v1/admin/categories", { method: "POST", body: JSON.stringify(body) }),
updateCategory: (id: string, body: unknown) =>
request<Category>(`/api/v1/admin/categories/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteCategory: (id: string) =>
request<{ ok: boolean }>(`/api/v1/admin/categories/${id}`, { method: "DELETE" }),
adminTags: () => request<Tag[]>("/api/v1/admin/tags"),
deleteTag: (id: string) =>
request<{ ok: boolean }>(`/api/v1/admin/tags/${id}`, { method: "DELETE" }),
adminViews: () => request<PortfolioView[]>("/api/v1/admin/views"),
createView: (body: unknown) =>
request<PortfolioView>("/api/v1/admin/views", { method: "POST", body: JSON.stringify(body) }),
updateView: (id: string, body: unknown) =>
request<PortfolioView>(`/api/v1/admin/views/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteView: (id: string) =>
request<{ ok: boolean }>(`/api/v1/admin/views/${id}`, { method: "DELETE" }),
adminResume: () => request<Resume | null>("/api/v1/admin/resume"),
saveResume: (body: unknown) =>
request<Resume>("/api/v1/admin/resume", { method: "PUT", body: JSON.stringify(body) }),
adminSettings: () => request<Record<string, unknown>>("/api/v1/admin/settings"),
saveSiteSettings: (body: unknown) =>
request<unknown>("/api/v1/admin/settings/site", {
method: "PUT",
body: JSON.stringify(body),
}),
reindex: () =>
request<{ ok: boolean; indexed: number }>("/api/v1/admin/reindex", { method: "POST" }),
};
export function mediaSrc(url?: string | null): string {
if (!url) return "";
if (url.startsWith("http") || url.startsWith("//")) return url;
return url;
}
export function thumbOf(p: Project): string {
return (
mediaSrc(p.thumbnail?.thumbnailUrl || p.thumbnail?.url) ||
mediaSrc(p.media?.find((m) => m.type === "image")?.thumbnailUrl) ||
mediaSrc(p.media?.find((m) => m.type === "image")?.url) ||
""
);
}