319 lines
9.5 KiB
TypeScript
319 lines
9.5 KiB
TypeScript
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;
|
|
/** 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<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" }),
|
|
|
|
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<ArtStationImportStatus>("/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<ArtStationImportStatus>("/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<ArtStationImportStatus>("/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) ||
|
|
""
|
|
);
|
|
}
|