Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import type {
|
|
TimelineData,
|
|
CatalogBootstrap,
|
|
YearBounds,
|
|
Artist,
|
|
ArtistDetail,
|
|
MovementGalleryDetail,
|
|
PaintingDetail,
|
|
ArtistNavigation,
|
|
} from '../types';
|
|
|
|
const API = '/api';
|
|
|
|
const fetchCredentials: RequestInit = { credentials: 'include' };
|
|
|
|
export type AuthRole = 'user' | 'curator';
|
|
|
|
export interface AuthState {
|
|
role: AuthRole;
|
|
username?: string;
|
|
}
|
|
|
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(url, { ...fetchCredentials, ...init });
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
export async function getAuthMe(): Promise<AuthState> {
|
|
return fetchJson<AuthState>(`${API}/auth/me`);
|
|
}
|
|
|
|
export async function loginCurator(username: string, password: string): Promise<AuthState> {
|
|
const res = await fetch(`${API}/auth/login`, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Login failed: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function logoutCurator(): Promise<void> {
|
|
const res = await fetch(`${API}/auth/logout`, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
|
|
}
|
|
|
|
export function imageUrl(path: string | null | undefined): string {
|
|
if (!path) return '/placeholder-art.svg';
|
|
return `/images/${path}`;
|
|
}
|
|
|
|
export function portraitUrl(path: string | null | undefined, revision?: number): string {
|
|
const base = imageUrl(path);
|
|
if (!revision || base.startsWith('/placeholder')) return base;
|
|
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
|
}
|
|
|
|
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
|
|
export function portraitThumbUrl(
|
|
artist: {
|
|
portrait_thumb_path?: string | null;
|
|
portrait_path?: string | null;
|
|
},
|
|
revision?: number
|
|
): string {
|
|
const path = artist.portrait_thumb_path || artist.portrait_path;
|
|
return portraitUrl(path, revision);
|
|
}
|
|
|
|
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
|
|
export function galleryImageUrl(painting: {
|
|
thumbnail_path?: string | null;
|
|
image_path?: string | null;
|
|
}): string | null {
|
|
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
|
if (painting.image_path) return `/images/${painting.image_path}`;
|
|
return null;
|
|
}
|
|
|
|
export function galleryImageUrlWithRevision(
|
|
painting: {
|
|
id?: number;
|
|
thumbnail_path?: string | null;
|
|
image_path?: string | null;
|
|
},
|
|
revision?: number
|
|
): string | null {
|
|
const base = galleryImageUrl(painting);
|
|
if (!base || !revision) return base;
|
|
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
|
}
|
|
|
|
export function paintingImageUrl(painting: {
|
|
id: number;
|
|
image_path?: string | null;
|
|
thumbnail_path?: string | null;
|
|
checkup_fixed?: boolean;
|
|
}): string | null {
|
|
if (painting.image_path) return `/images/${painting.image_path}`;
|
|
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
|
if (painting.checkup_fixed) return null;
|
|
return `/api/paintings/${painting.id}/image?size=full`;
|
|
}
|
|
|
|
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result;
|
|
if (typeof result !== 'string') {
|
|
reject(new Error('Could not read file'));
|
|
return;
|
|
}
|
|
const comma = result.indexOf(',');
|
|
resolve({
|
|
imageData: comma >= 0 ? result.slice(comma + 1) : result,
|
|
mimeType: file.type || 'image/jpeg',
|
|
});
|
|
};
|
|
reader.onerror = () => reject(new Error('Could not read file'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
|
const res = await fetch(url, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
|
const res = await fetch(`${API}/artists/${artistId}/preload-images`, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) throw new Error('Preload failed');
|
|
return res.json();
|
|
}
|
|
|
|
export interface FixPaintingImageResult {
|
|
imagePath: string | null;
|
|
thumbnailPath: string | null;
|
|
fixed?: boolean;
|
|
checked?: boolean;
|
|
}
|
|
|
|
export interface FixArtistPortraitResult {
|
|
portraitPath: string | null;
|
|
fixed?: boolean;
|
|
checked?: boolean;
|
|
}
|
|
|
|
export interface DebugImageSearchResult {
|
|
query: string;
|
|
imageUrl: string | null;
|
|
searchUrl: string;
|
|
source: string;
|
|
sourceLabel?: string;
|
|
thumbUrl?: string;
|
|
}
|
|
|
|
export interface DebugImageSearchResultItem {
|
|
imageUrl: string;
|
|
thumbUrl?: string;
|
|
source: string;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export interface DebugImageSearchManyResult {
|
|
query: string;
|
|
searchUrl: string;
|
|
source: string;
|
|
sourceLabel?: string;
|
|
results: DebugImageSearchResultItem[];
|
|
}
|
|
|
|
export interface PaintingCheckupRow {
|
|
id: number;
|
|
title: string;
|
|
artist_name: string;
|
|
year: number | null;
|
|
gallery_file: string | null;
|
|
gallery_preview: string | null;
|
|
detail_file: string;
|
|
detail_preview: string | null;
|
|
detail_on_demand: boolean;
|
|
gallery_file_exists: boolean;
|
|
detail_file_exists: boolean | null;
|
|
checked: boolean;
|
|
fixed: boolean;
|
|
}
|
|
|
|
export interface PaintingCheckupData {
|
|
paintings: PaintingCheckupRow[];
|
|
total: number;
|
|
}
|
|
|
|
export const api = {
|
|
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
|
|
|
getCatalogBootstrap: (start?: number, end?: number) => {
|
|
const params = new URLSearchParams();
|
|
if (start != null) params.set('start', String(start));
|
|
if (end != null) params.set('end', String(end));
|
|
const qs = params.toString();
|
|
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
getTimeline: (start: number, end: number) =>
|
|
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
|
|
|
getArtists: (start?: number, end?: number, movementId?: number) => {
|
|
const params = new URLSearchParams();
|
|
if (start != null) params.set('start', String(start));
|
|
if (end != null) params.set('end', String(end));
|
|
if (movementId != null) params.set('movement_id', String(movementId));
|
|
return fetchJson<Artist[]>(`${API}/artists?${params}`);
|
|
},
|
|
|
|
/** Lightweight artist rows for the timeline (no biography text). */
|
|
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
|
|
|
|
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
|
|
|
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
|
|
|
getArtistNavigation: (id: number) =>
|
|
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
|
|
|
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
|
|
|
getPaintingDebugImageSearch: (id: number) =>
|
|
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
|
|
|
getPaintingDebugImageSearchMore: (id: number, limit = 20) =>
|
|
fetchJson<DebugImageSearchManyResult>(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`),
|
|
|
|
fixPaintingImage: (
|
|
id: number,
|
|
imageUrl: string,
|
|
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
|
) =>
|
|
fetch(`${API}/paintings/${id}/fix-image`, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ imageUrl, ...context }),
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Fix failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<FixPaintingImageResult>;
|
|
}),
|
|
|
|
clearPaintingImage: (id: number) =>
|
|
fetch(`${API}/paintings/${id}/clear-image`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Clear failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<FixPaintingImageResult>;
|
|
}),
|
|
|
|
deletePainting: (id: number) =>
|
|
fetch(`${API}/paintings/${id}`, { ...fetchCredentials, method: 'DELETE' }).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Remove failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<{ id: number; artistId: number; title: string }>;
|
|
}),
|
|
|
|
uploadPaintingImage: async (id: number, file: File) => {
|
|
const payload = await fileToBase64Payload(file);
|
|
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
|
|
},
|
|
|
|
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
|
|
|
updatePaintingCheckupFlags: (
|
|
id: number,
|
|
flags: { checked?: boolean; fixed?: boolean }
|
|
) =>
|
|
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
|
...fetchCredentials,
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(flags),
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Update failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
|
}),
|
|
|
|
getArtistDebugPortraitSearch: (id: number) =>
|
|
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
|
|
|
|
getArtistDebugPortraitSearchMore: (id: number, limit = 20) =>
|
|
fetchJson<DebugImageSearchManyResult>(`${API}/artists/${id}/debug-portrait-search/more?limit=${limit}`),
|
|
|
|
fixArtistPortrait: (
|
|
id: number,
|
|
imageUrl: string,
|
|
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
|
) =>
|
|
fetch(`${API}/artists/${id}/fix-portrait`, {
|
|
...fetchCredentials,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ imageUrl, ...context }),
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Fix failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<FixArtistPortraitResult>;
|
|
}),
|
|
|
|
clearArtistPortrait: (id: number) =>
|
|
fetch(`${API}/artists/${id}/clear-portrait`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Clear failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<FixArtistPortraitResult>;
|
|
}),
|
|
|
|
uploadArtistPortrait: async (id: number, file: File) => {
|
|
const payload = await fileToBase64Payload(file);
|
|
return postJsonImageAction<FixArtistPortraitResult>(`${API}/artists/${id}/upload-portrait`, payload);
|
|
},
|
|
|
|
updateArtistCheckupFlags: (
|
|
id: number,
|
|
flags: { checked?: boolean; fixed?: boolean }
|
|
) =>
|
|
fetch(`${API}/artists/${id}/checkup-flags`, {
|
|
...fetchCredentials,
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(flags),
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Update failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
|
}),
|
|
|
|
preloadArtistImages,
|
|
};
|
|
|
|
export function debugImageProxyUrl(
|
|
imageUrl: string,
|
|
context?: { searchUrl?: string; source?: string }
|
|
): string {
|
|
const params = new URLSearchParams({ url: imageUrl });
|
|
if (context?.searchUrl) params.set('searchUrl', context.searchUrl);
|
|
if (context?.source) params.set('source', context.source);
|
|
return `${API}/debug/image-proxy?${params.toString()}`;
|
|
}
|