Initial portfolio CMS: Fastify API + Vite SPA
Single-container app with Postgres catalog, Typesense search, Authelia Remote-User admin, portfolio views, resume/PDF, media uploads, and ArtStation import tooling.
This commit is contained in:
@@ -0,0 +1,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) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user