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; heroTitle?: string; heroSubtitle?: string; } export interface ListMeta { page: number; perPage: number; total: number; totalPages: number; /** all | category (hard filter) | view (audience prioritization) */ mode?: "all" | "category" | "view"; category?: Category; view?: { slug: string; name: string; description?: string | null; categories?: Category[]; }; search?: boolean; } async function request(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers); // Only set JSON content-type when we actually send a body (DELETE/GET have none — // Fastify rejects empty body + application/json). if (init?.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const res = await fetch(path, { ...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; // Some DELETEs return empty body const text = await res.text(); if (!text) return undefined as T; return JSON.parse(text) as T; } export const api = { site: () => request("/api/v1/site"), categories: () => request("/api/v1/categories"), tags: () => request("/api/v1/tags"), projects: (params: Record = {}) => { 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(`/api/v1/projects/${slug}`), featured: () => request("/api/v1/featured"), views: () => request("/api/v1/views"), view: (slug: string) => request(`/api/v1/views/${slug}`), resume: () => request("/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 = {}) => { 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(`/api/v1/admin/projects/${id}`), createProject: (body: unknown) => request("/api/v1/admin/projects", { method: "POST", body: JSON.stringify(body) }), updateProject: (id: string, body: unknown) => request(`/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("/api/v1/admin/media/upload", { method: "POST", body: fd }); }, addVideoLink: (url: string, projectId?: string) => request("/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(`/api/v1/admin/projects/${projectId}/thumbnail`, { method: "POST", body: JSON.stringify({ mediaId }), }), adminCategories: () => request("/api/v1/admin/categories"), createCategory: (body: unknown) => request("/api/v1/admin/categories", { method: "POST", body: JSON.stringify(body) }), updateCategory: (id: string, body: unknown) => request(`/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("/api/v1/admin/tags"), deleteTag: (id: string) => request<{ ok: boolean }>(`/api/v1/admin/tags/${id}`, { method: "DELETE" }), adminViews: () => request("/api/v1/admin/views"), createView: (body: unknown) => request("/api/v1/admin/views", { method: "POST", body: JSON.stringify(body) }), updateView: (id: string, body: unknown) => request(`/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("/api/v1/admin/resume"), saveResume: (body: unknown) => request("/api/v1/admin/resume", { method: "PUT", body: JSON.stringify(body) }), adminSettings: () => request>("/api/v1/admin/settings"), saveSiteSettings: (body: unknown) => request("/api/v1/admin/settings/site", { method: "PUT", body: JSON.stringify(body), }), reindex: () => request<{ ok: boolean; indexed: number }>("/api/v1/admin/reindex", { method: "POST" }), importArtStation: (body: { username?: string; dryRun?: boolean; publish?: boolean; url?: string; }) => request<{ ok: boolean; message: string; status: ArtStationImportStatus; }>("/api/v1/admin/import/artstation", { method: "POST", body: JSON.stringify(body), }), importArtStationStatus: () => request("/api/v1/admin/import/artstation"), importFlickr: (body: { username?: string; dryRun?: boolean; publish?: boolean }) => request<{ ok: boolean; message: string; status: ArtStationImportStatus; }>("/api/v1/admin/import/flickr", { method: "POST", body: JSON.stringify(body), }), importFlickrStatus: () => request("/api/v1/admin/import/flickr"), importVimeo: (body: { username?: string; dryRun?: boolean; publish?: boolean }) => request<{ ok: boolean; message: string; status: ArtStationImportStatus; }>("/api/v1/admin/import/vimeo", { method: "POST", body: JSON.stringify(body), }), importVimeoStatus: () => request("/api/v1/admin/import/vimeo"), }; export interface ArtStationImportStatus { running: boolean; startedAt?: string; finishedAt?: string; error?: string; log: string[]; result?: { username: string; dryRun: boolean; found: number; imported: number; skipped: number; errors: number; created: { title: string; slug: string }[]; }; } 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) || "" ); }