Files
Art-gallery/client/src/api/client.ts
T
Danila Khodjaef 99b8559607 Speed up timeline load with portrait thumbs, bootstrap API, and caching.
Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
2026-07-06 14:27:29 +03:00

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()}`;
}