1015 lines
32 KiB
TypeScript
1015 lines
32 KiB
TypeScript
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<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 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<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, 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<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(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<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 async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
|
|
const res = await fetch(`${API}/movements/${movementId}/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<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));
|
|
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
|
|
},
|
|
|
|
getTimeline: (start: number, end: number) => {
|
|
const params = new URLSearchParams({ start: String(start), end: String(end) });
|
|
return fetchJson<TimelineData>(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<CatalogSearchResponse>(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<Artist[]>(localizedPath(`${API}/artists`, params));
|
|
},
|
|
|
|
/** Lightweight artist rows for the timeline (no biography text). */
|
|
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
|
|
|
|
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
|
|
|
|
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
|
|
|
getArtistNavigation: (id: number) =>
|
|
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
|
|
|
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${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 }>;
|
|
}),
|
|
|
|
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<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 }>;
|
|
}),
|
|
|
|
getTranslationCoverage: (locale = 'ru') =>
|
|
fetchJson<{ locale: string; coverage: Record<string, number> }>(
|
|
`${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<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
|
|
|
|
saveEntityTranslation: (
|
|
entityType: string,
|
|
id: number,
|
|
payload: { locale: string; fields: Record<string, string>; 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<InfluenceGraph>(`${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<InfluenceImportParseResult>;
|
|
});
|
|
},
|
|
|
|
previewInfluenceImport: (payload: {
|
|
rows: Record<string, string>[];
|
|
mapping: Record<string, string>;
|
|
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<InfluenceImportPreview>;
|
|
}),
|
|
|
|
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<TourGalleryDetail & { locale?: string }>(`${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<number, string>;
|
|
}>;
|
|
}),
|
|
|
|
preloadArtistImages,
|
|
preloadMovementImages,
|
|
};
|
|
|
|
export interface TranslationWorklistItem {
|
|
entityId: number;
|
|
label: string;
|
|
publishedCount: number;
|
|
draftCount: number;
|
|
missingFields: string[];
|
|
}
|
|
|
|
export interface TranslationDetail {
|
|
entityType: string;
|
|
entityId: number;
|
|
canonical: Record<string, unknown>;
|
|
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<string, string>[];
|
|
rows?: Record<string, string>[];
|
|
suggestedPreset: string;
|
|
suggestedMapping: Record<string, string>;
|
|
roles: string[];
|
|
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
|
|
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()}`;
|
|
}
|