Files
jmartgraphix.com/client/src/lib/api.ts
T

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) ||
""
);
}