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(url: string, init?: RequestInit): Promise { 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 { return fetchJson(`${API}/auth/me`); } export async function loginCurator(username: string, password: string): Promise { 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 { 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(url: string, payload: { imageData: string; mimeType: string }): Promise { 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; } 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(`${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(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`); }, getTimeline: (start: number, end: number) => fetchJson(`${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(`${API}/artists?${params}`); }, /** Lightweight artist rows for the timeline (no biography text). */ getTimelineArtists: () => fetchJson(`${API}/artists?timeline=1`), getArtist: (id: number) => fetchJson(`${API}/artists/${id}`), getMovementGallery: (id: number) => fetchJson(`${API}/movements/${id}/gallery`), getArtistNavigation: (id: number) => fetchJson(`${API}/artists/${id}/navigation`), getPainting: (id: number) => fetchJson(`${API}/paintings/${id}`), getPaintingDebugImageSearch: (id: number) => fetchJson(`${API}/paintings/${id}/debug-image-search`), getPaintingDebugImageSearchMore: (id: number, limit = 20) => fetchJson(`${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; }), 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; }), 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(`${API}/paintings/${id}/upload-image`, payload); }, getPaintingCheckup: () => fetchJson(`${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(`${API}/artists/${id}/debug-portrait-search`), getArtistDebugPortraitSearchMore: (id: number, limit = 20) => fetchJson(`${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; }), 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; }), uploadArtistPortrait: async (id: number, file: File) => { const payload = await fileToBase64Payload(file); return postJsonImageAction(`${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()}`; }