import type { TimelineData, CatalogBootstrap, YearBounds, Artist, ArtistDetail, MovementGalleryDetail, Painting, PaintingDetail, ArtistNavigation, CatalogSearchResponse, TourSummary, TourGalleryDetail, } from '../types'; import { readStoredLocale, type AppLocale } from '../utils/localeStorage'; const API = '/api'; let apiLocale: AppLocale = readStoredLocale(); export function setApiLocale(locale: AppLocale) { apiLocale = locale; } export function getApiLocale(): AppLocale { return apiLocale; } function localeParams(base?: URLSearchParams): URLSearchParams { const params = base ?? new URLSearchParams(); if (apiLocale !== 'en') params.set('locale', apiLocale); return params; } function localizedPath(path: string, params?: URLSearchParams): string { const qs = localeParams(params).toString(); return qs ? `${path}?${qs}` : path; } const fetchCredentials: RequestInit = { credentials: 'include' }; export type AuthRole = 'user' | 'admin' | 'curator'; export type StaffPermission = | 'images' | 'checkup' | 'curator_notes' | 'translations' | 'influences' | 'tours' | 'users'; export const ALL_STAFF_PERMISSIONS: StaffPermission[] = [ 'images', 'checkup', 'curator_notes', 'translations', 'influences', 'tours', 'users', ]; export interface AuthState { role: AuthRole; username?: string; permissions?: StaffPermission[]; } export interface StaffUser { id: number; username: string; role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean; created_at: string; last_login_at: string | null; } 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 contentType = res.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { throw new Error( res.status === 401 ? 'Login blocked by the reverse proxy (not the gallery). Use http://localhost:5173 or fix Keenetic access.' : `Login failed: HTTP ${res.status}` ); } 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, revision?: number | null): string { const base = !path ? '/placeholder-art.svg' : `/images/${path}`; if (!revision || base.startsWith('/placeholder')) return base; return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; } export function portraitUrl( path: string | null | undefined, revision?: number | null, options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null } ): string { const cacheRevision = revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined; return imageUrl(path, cacheRevision); } export function portraitThumbUrl( artist: { portrait_thumb_path?: string | null; portrait_path?: string | null; portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null; }, revision?: number | null ): string { const path = artist.portrait_thumb_path || artist.portrait_path; const cacheRevision = revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined; return portraitUrl(path, cacheRevision); } export function paintingImageRevision( painting: { image_cache_key?: number | null; thumbnail_cache_key?: number | null; }, sessionRevision?: number | null ): number | undefined { const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key; if (apiRevision != null) return apiRevision; if (sessionRevision != null) return sessionRevision; return undefined; } /** Image for 3D gallery — prefer thumbnail for faster texture loads */ export function galleryImageUrl( painting: { thumbnail_path?: string | null; image_path?: string | null; image_cache_key?: number | null; thumbnail_cache_key?: number | null; }, sessionRevision?: number | null ): string | null { const revision = paintingImageRevision(painting, sessionRevision); if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision); if (painting.image_path) return imageUrl(painting.image_path, revision); return null; } export function galleryImageUrlWithRevision( painting: { id?: number; thumbnail_path?: string | null; image_path?: string | null; image_cache_key?: number | null; thumbnail_cache_key?: number | null; }, sessionRevision?: number | null ): string | null { return galleryImageUrl(painting, sessionRevision); } export function paintingImageUrl( painting: { id: number; image_path?: string | null; thumbnail_path?: string | null; checkup_fixed?: boolean; image_cache_key?: number | null; thumbnail_cache_key?: number | null; }, sessionRevision?: number | null ): string | null { const revision = paintingImageRevision(painting, sessionRevision); if (painting.image_path) return imageUrl(painting.image_path, revision); if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision); if (painting.checkup_fixed) return null; return `/api/paintings/${painting.id}/image?size=full`; } async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> { validateDebugUploadFile(file); 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 || mimeTypeFromFilename(file.name) || 'image/jpeg', }); }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsDataURL(file); }); } async function fileToBase64Raw(file: File): Promise { 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(comma >= 0 ? result.slice(comma + 1) : result); }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsDataURL(file); }); } function mimeTypeFromFilename(filename: string): string | null { const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; switch (ext) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'webp': return 'image/webp'; case 'gif': return 'image/gif'; case 'avif': return 'image/avif'; default: return null; } } export function validateDebugUploadFile(file: File): void { const maxBytes = 15 * 1024 * 1024; if (file.size <= 0) { throw new Error('Selected file is empty.'); } if (file.size > maxBytes) { throw new Error('Image too large (max 15 MB).'); } const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name); if (!file.type.startsWith('image/') && !nameOk) { throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).'); } } 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; image_cache_key?: number | null; thumbnail_cache_key?: number | null; fixed?: boolean; checked?: boolean; } export interface FixArtistPortraitResult { portraitPath: string | null; portraitThumbPath?: string | null; portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | 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 = { listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`), createUser: (body: { username: string; password: string; role: 'admin' | 'curator'; permissions: StaffPermission[]; }) => fetch(`${API}/users`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then(async (res) => { const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`); return data as { user: StaffUser }; }), updateUser: ( id: number, body: Partial<{ role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean }> ) => fetch(`${API}/users/${id}`, { ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then(async (res) => { const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Update failed: ${res.status}`); return data as { user: StaffUser }; }), resetUserPassword: (id: number, password: string) => fetch(`${API}/users/${id}/password`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }).then(async (res) => { const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Password reset failed: ${res.status}`); return data as { ok: boolean }; }), 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)); return fetchJson(localizedPath(`${API}/catalog/bootstrap`, params)); }, getTimeline: (start: number, end: number) => { const params = new URLSearchParams({ start: String(start), end: String(end) }); return fetchJson(localizedPath(`${API}/timeline`, params)); }, search: (q: string, options?: { limit?: number; types?: string }) => { const params = new URLSearchParams({ q }); if (options?.limit != null) params.set('limit', String(options.limit)); if (options?.types) params.set('types', options.types); return fetchJson(localizedPath(`${API}/search`, params)); }, 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(localizedPath(`${API}/artists`, params)); }, /** Lightweight artist rows for the timeline (no biography text). */ getTimelineArtists: () => fetchJson(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))), getArtist: (id: number) => fetchJson(localizedPath(`${API}/artists/${id}`)), getMovementGallery: (id: number) => fetchJson(localizedPath(`${API}/movements/${id}/gallery`)), getArtistNavigation: (id: number) => fetchJson(localizedPath(`${API}/artists/${id}/navigation`)), getPainting: (id: number) => fetchJson(localizedPath(`${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 }>; }), updatePaintingCuratorNotes: (id: number, curatorNotes: string) => fetch(`${API}/paintings/${id}/curator-notes`, { ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ curatorNotes }), }).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<{ curatorNotes: string }>; }), 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 }>; }), getTranslationCoverage: (locale = 'ru') => fetchJson<{ locale: string; coverage: Record }>( `${API}/translations/coverage?locale=${encodeURIComponent(locale)}` ), getTranslationWorklist: (entityType: string, locale = 'ru') => fetchJson<{ items: TranslationWorklistItem[] }>( `${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}` ), getEntityTranslation: (entityType: string, id: number) => fetchJson(`${API}/translations/${encodeURIComponent(entityType)}/${id}`), saveEntityTranslation: ( entityType: string, id: number, payload: { locale: string; fields: Record; status?: string } ) => fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, { ...fetchCredentials, method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Save failed: ${res.status}`); } return res.json(); }), publishEntityTranslation: (entityType: string, id: number, locale = 'ru') => fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ locale }), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Publish failed: ${res.status}`); } return res.json(); }), listInfluences: (params: { artistId?: number; paintingId?: number; q?: string; limit?: number; offset?: number; } = {}) => { const qs = new URLSearchParams(); if (params.artistId) qs.set('artistId', String(params.artistId)); if (params.paintingId) qs.set('paintingId', String(params.paintingId)); if (params.q) qs.set('q', params.q); if (params.limit) qs.set('limit', String(params.limit)); if (params.offset) qs.set('offset', String(params.offset)); const q = qs.toString(); return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>( `${API}/influences${q ? `?${q}` : ''}`, ); }, getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => { const qs = new URLSearchParams(); if (params.artistId) qs.set('artistId', String(params.artistId)); if (params.paintingId) qs.set('paintingId', String(params.paintingId)); return fetchJson(`${API}/influences/graph?${qs.toString()}`); }, createInfluence: (payload: { paintingId: number; sourceType: 'painting' | 'artist' | 'movement'; sourcePaintingId?: number; sourceArtistId?: number; sourceMovementId?: number; notes?: string; source?: string; sourceUrl?: string; }) => fetch(`${API}/influences`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Create failed: ${res.status}`); } return res.json() as Promise<{ id: number }>; }), updateInfluence: ( id: number, payload: Partial<{ notes: string; source: string; sourceUrl: string; confidence: string; sourceType: 'painting' | 'artist' | 'movement'; sourcePaintingId: number; sourceArtistId: number; sourceMovementId: number; }>, ) => fetch(`${API}/influences/${id}`, { ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).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<{ id: number }>; }), deleteInfluence: (id: number) => fetch(`${API}/influences/${id}`, { ...fetchCredentials, method: 'DELETE', }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Delete failed: ${res.status}`); } return res.json() as Promise<{ ok: boolean }>; }), parseInfluenceImport: async (file: File, sheet?: string) => { const contentBase64 = await fileToBase64Raw(file); return fetch(`${API}/influences/import/parse`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, sheet, contentBase64, }), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Parse failed: ${res.status}`); } return res.json() as Promise; }); }, previewInfluenceImport: (payload: { rows: Record[]; mapping: Record; sourceLabel?: string; contentHash?: string; payloadHash?: string; }) => fetch(`${API}/influences/import/preview`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Preview failed: ${res.status}`); } return res.json() as Promise; }), commitInfluenceImport: (payload: { proposals: InfluenceImportProposal[]; fileName?: string; contentHash?: string; payloadHash?: string; force?: boolean; }) => fetch(`${API}/influences/import/commit`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & { code?: string; priorImport?: InfluencePriorImport; }; err.code = body.code; err.priorImport = body.priorImport; throw err; } return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>; }), listPublishedTours: () => fetchJson<{ tours: TourSummary[] }>(`${API}/tours`), listAdminTours: () => fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`), getTour: (id: number) => fetchJson(`${API}/tours/${id}`), createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) => fetch(`${API}/tours`, { ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Create failed: ${res.status}`); } return res.json() as Promise<{ tour: TourSummary }>; }), updateTour: ( id: number, payload: Partial<{ title: string; description: string; status: 'draft' | 'published'; coverPaintingId: number | null; }>, ) => fetch(`${API}/tours/${id}`, { ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).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<{ tour: TourSummary }>; }), deleteTour: (id: number) => fetch(`${API}/tours/${id}`, { ...fetchCredentials, method: 'DELETE', }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Delete failed: ${res.status}`); } return res.json() as Promise<{ ok: boolean }>; }), saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) => fetch(`${API}/tours/${id}/stops`, { ...fetchCredentials, method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stops }), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Save stops failed: ${res.status}`); } return res.json() as Promise<{ ok: boolean; stopCount: number; paintings: Painting[]; stopBodies: Record; }>; }), preloadArtistImages, }; export interface TranslationWorklistItem { entityId: number; label: string; publishedCount: number; draftCount: number; missingFields: string[]; } export interface TranslationDetail { entityType: string; entityId: number; canonical: Record; translatableFields: string[]; translations: Array<{ locale: string; field_name: string; value: string; status: string; source: string | null; updated_at: string; }>; } export interface InfluenceEdgeItem { id: number; paintingId: number; paintingTitle: string; paintingYear: number | null; artistId: number; artistName: string; sourceType: 'painting' | 'artist' | 'movement'; sourcePaintingId: number | null; sourceArtistId: number | null; sourceMovementId: number | null; sourceLabel: string | null; notes: string | null; source: string | null; sourceUrl: string | null; aspects: string | null; quote: string | null; confidence: string | null; discoveredVia: string | null; updatedAt: string | null; } export interface InfluenceGraph { focus: { artistId: number; paintingId: number | null; label: string }; nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>; edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>; } export interface InfluencePriorImport { importedAt: string; username: string | null; fileName: string | null; inserted: number | null; contentHash: string | null; payloadHash: string | null; match: 'file' | 'data' | 'unknown'; } export type { TourSummary, TourGalleryDetail }; export interface InfluenceImportParseResult { filename: string; format: string; sheets: string[] | null; sheet: string | null; columns: string[]; rowCount: number; sampleRows: Record[]; rows?: Record[]; suggestedPreset: string; suggestedMapping: Record; roles: string[]; presets: Array<{ id: string; label: string; mapping: Record }>; contentHash?: string; payloadHash?: string; alreadyImported?: boolean; priorImport?: InfluencePriorImport | null; } export interface InfluenceImportProposal { rowIndex: number; direction: string; paintingId: number; paintingTitle: string; artistId: number; artistName: string; sourceType: string; sourcePaintingId: number | null; sourceArtistId: number | null; sourceMovementId: number | null; sourceLabel: string; token: string; notes: string | null; source: string | null; sourceUrl: string | null; confidence: string; discoveredVia: string; edgeKey: string; action: 'create' | 'skip'; reason: string | null; } export interface InfluenceImportPreview { proposals: InfluenceImportProposal[]; warnings: Array<{ rowIndex: number; message: string; token?: string; direction?: string; candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>; }>; counts: { rows: number; proposals: number; willCreate: number; willSkip: number; errors: number; }; contentHash?: string | null; payloadHash?: string | null; alreadyImported?: boolean; priorImport?: InfluencePriorImport | null; } 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()}`; }