Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<title>Virtual Art Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+76
-1
@@ -10,8 +10,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
@@ -2077,6 +2079,43 @@
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -2815,6 +2854,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
|
||||
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||
@@ -3159,7 +3225,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3337,6 +3403,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
|
||||
+632
-38
@@ -1,39 +1,140 @@
|
||||
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';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
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' | '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 function imageUrl(path: string | null | undefined): string {
|
||||
if (!path) return '/placeholder-art.svg';
|
||||
return `/images/${path}`;
|
||||
export async function getAuthMe(): Promise<AuthState> {
|
||||
return fetchJson<AuthState>(`${API}/auth/me`);
|
||||
}
|
||||
|
||||
export function portraitUrl(path: string | null | undefined, revision?: number): string {
|
||||
const base = imageUrl(path);
|
||||
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, 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}`;
|
||||
}
|
||||
|
||||
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
|
||||
export function galleryImageUrl(painting: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -42,27 +143,35 @@ export function galleryImageUrlWithRevision(
|
||||
id?: number;
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
revision?: number
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const base = galleryImageUrl(painting);
|
||||
if (!base || !revision) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
return galleryImageUrl(painting, sessionRevision);
|
||||
}
|
||||
|
||||
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}`;
|
||||
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 = () => {
|
||||
@@ -74,7 +183,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
const comma = result.indexOf(',');
|
||||
resolve({
|
||||
imageData: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
@@ -82,8 +191,59 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -96,7 +256,10 @@ async function postJsonImageAction<T>(url: string, payload: { imageData: string;
|
||||
}
|
||||
|
||||
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
|
||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error('Preload failed');
|
||||
return res.json();
|
||||
}
|
||||
@@ -104,12 +267,17 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
||||
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;
|
||||
}
|
||||
@@ -163,25 +331,44 @@ export interface PaintingCheckupData {
|
||||
export const api = {
|
||||
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
||||
|
||||
getTimeline: (start: number, end: number) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
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[]>(`${API}/artists?${params}`);
|
||||
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
|
||||
},
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
/** Lightweight artist rows for the timeline (no biography text). */
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||
|
||||
getArtistNavigation: (id: number) =>
|
||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
||||
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
|
||||
|
||||
getPaintingDebugImageSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||
@@ -195,6 +382,7 @@ export const api = {
|
||||
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 }),
|
||||
@@ -207,7 +395,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
clearPaintingImage: (id: number) =>
|
||||
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
|
||||
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}`);
|
||||
@@ -216,7 +404,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
deletePainting: (id: number) =>
|
||||
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||
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}`);
|
||||
@@ -236,6 +424,7 @@ export const api = {
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
@@ -259,6 +448,7 @@ export const api = {
|
||||
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 }),
|
||||
@@ -271,7 +461,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
clearArtistPortrait: (id: number) =>
|
||||
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
|
||||
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}`);
|
||||
@@ -289,6 +479,7 @@ export const api = {
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/artists/${id}/checkup-flags`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
@@ -299,8 +490,411 @@ export const api = {
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
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 }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.artist-bio {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e8d5b5;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Artist } from '../types';
|
||||
import {
|
||||
api,
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type FixArtistPortraitResult,
|
||||
} from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import '../components/PaintingDetail.css';
|
||||
import '../components/GalleryLoadingMarker.css';
|
||||
import './ArtistBio.css';
|
||||
|
||||
interface Props {
|
||||
@@ -51,11 +54,13 @@ export default function ArtistBio({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
|
||||
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
|
||||
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
|
||||
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
|
||||
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
|
||||
const portraitSrc = uploading
|
||||
? null
|
||||
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
|
||||
|
||||
const lifespan =
|
||||
artist.birth_year && artist.death_year
|
||||
@@ -71,6 +76,9 @@ export default function ArtistBio({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -91,7 +99,7 @@ export default function ArtistBio({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, artist.id, artist.name]);
|
||||
}, [debugMode, uploading, artist.id, artist.name]);
|
||||
|
||||
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
|
||||
if (onArtistPortraitFixed) {
|
||||
@@ -197,20 +205,27 @@ export default function ArtistBio({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadArtistPortrait(artist.id, file);
|
||||
await applyPortraitUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -219,6 +234,13 @@ export default function ArtistBio({
|
||||
|
||||
return (
|
||||
<div className="artist-bio">
|
||||
{uploading && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="debug-upload-page-overlay"
|
||||
message={uploadStatus ?? 'Loading…'}
|
||||
/>
|
||||
)}
|
||||
<header className="bio-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back</button>
|
||||
<h1>{artist.name}</h1>
|
||||
@@ -227,9 +249,9 @@ export default function ArtistBio({
|
||||
|
||||
<div className="bio-content">
|
||||
<div
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
|
||||
>
|
||||
{showPortrait ? (
|
||||
{showPortrait && portraitSrc ? (
|
||||
<img
|
||||
src={portraitSrc}
|
||||
alt={artist.name}
|
||||
@@ -273,14 +295,17 @@ export default function ArtistBio({
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" aria-label="Debug portrait search">
|
||||
<aside
|
||||
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||||
aria-label="Debug portrait search"
|
||||
>
|
||||
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
|
||||
<p className="debug-image-query">
|
||||
{debugSearch?.query ?? `${artist.name} portrait`}
|
||||
</p>
|
||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||
{!debugLoading && debugSearch?.imageUrl && (
|
||||
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||||
{!uploading && !debugLoading && debugSearch?.imageUrl && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
@@ -293,7 +318,7 @@ export default function ArtistBio({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
@@ -301,7 +326,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
@@ -309,7 +334,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixPortrait}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -317,7 +342,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -331,27 +356,18 @@ export default function ArtistBio({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose portrait"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
.catalog-search {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(520px, 100%);
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
|
||||
.catalog-search-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.catalog-search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.catalog-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 40px 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
background: rgba(15, 15, 26, 0.92);
|
||||
color: #e8d5b5;
|
||||
font-size: 15px;
|
||||
font-family: Georgia, serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.catalog-search-input::placeholder {
|
||||
color: rgba(232, 213, 181, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-input:focus {
|
||||
border-color: #c9a96e;
|
||||
box-shadow: 0 0 0 2px rgba(201, 169, 110, 0.18);
|
||||
}
|
||||
|
||||
.catalog-search-spinner {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(201, 169, 110, 0.22);
|
||||
border-top-color: #c9a96e;
|
||||
animation: catalog-search-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.catalog-search-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
max-height: min(420px, 60vh);
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(12, 12, 22, 0.98);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-message {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.catalog-search-error {
|
||||
color: #e8a0a0;
|
||||
}
|
||||
|
||||
.catalog-search-group + .catalog-search-group {
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-group-label {
|
||||
margin: 0;
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.catalog-search-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
|
||||
.catalog-search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e8d5b5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.catalog-search-option:hover,
|
||||
.catalog-search-option-active {
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.catalog-search-movement-swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-search-option-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.catalog-search-option-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(232, 213, 181, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes catalog-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatalogSearchResult } from '../types';
|
||||
import { api, imageUrl, portraitThumbUrl } from '../api/client';
|
||||
import './CatalogSearchBar.css';
|
||||
|
||||
interface Props {
|
||||
onSelectArtist: (artistId: number) => void;
|
||||
onSelectMovement: (movementId: number) => void;
|
||||
onSelectPainting: (paintingId: number) => void;
|
||||
}
|
||||
|
||||
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
|
||||
|
||||
function resultKey(item: CatalogSearchResult): string {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function paintingThumbSrc(item: Extract<CatalogSearchResult, { type: 'painting' }>): string {
|
||||
const path = item.thumbnail_path || item.image_path;
|
||||
return path ? imageUrl(path) : '/placeholder-art.svg';
|
||||
}
|
||||
|
||||
export default function CatalogSearchBar({
|
||||
onSelectArtist,
|
||||
onSelectMovement,
|
||||
onSelectPainting,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('search');
|
||||
const typeLabels: Record<CatalogSearchResult['type'], string> = {
|
||||
artist: t('groupArtist'),
|
||||
movement: t('groupMovement'),
|
||||
painting: t('groupPainting'),
|
||||
};
|
||||
const listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<CatalogSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<CatalogSearchResult['type'], CatalogSearchResult[]>();
|
||||
for (const type of TYPE_ORDER) map.set(type, []);
|
||||
for (const item of results) {
|
||||
map.get(item.type)?.push(item);
|
||||
}
|
||||
return TYPE_ORDER.map((type) => ({ type, items: map.get(type) ?? [] })).filter((g) => g.items.length > 0);
|
||||
}, [results]);
|
||||
|
||||
const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
|
||||
|
||||
const activate = useCallback(
|
||||
(item: CatalogSearchResult) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setActiveIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
if (item.type === 'artist') onSelectArtist(item.id);
|
||||
else if (item.type === 'movement') onSelectMovement(item.id);
|
||||
else onSelectPainting(item.id);
|
||||
},
|
||||
[onSelectArtist, onSelectMovement, onSelectPainting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
api
|
||||
.search(trimmed)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setResults(data.results);
|
||||
setOpen(true);
|
||||
setActiveIndex(data.results.length > 0 ? 0 : -1);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setResults([]);
|
||||
setError(t('searchFailed'));
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (!open || flatResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % flatResults.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? flatResults.length - 1 : i - 1));
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
activate(flatResults[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const showPanel = open && query.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div className="catalog-search" ref={rootRef}>
|
||||
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
|
||||
{t('label')}
|
||||
</label>
|
||||
<div className="catalog-search-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={`${listboxId}-input`}
|
||||
className="catalog-search-input"
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={showPanel}
|
||||
aria-controls={showPanel ? `${listboxId}-listbox` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
|
||||
}
|
||||
placeholder={t('placeholder')}
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (query.trim().length >= 2) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{loading && <span className="catalog-search-spinner" aria-hidden />}
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div
|
||||
id={`${listboxId}-listbox`}
|
||||
className="catalog-search-panel"
|
||||
role="listbox"
|
||||
aria-label={t('resultsLabel')}
|
||||
>
|
||||
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
|
||||
{!error && !loading && flatResults.length === 0 && (
|
||||
<p className="catalog-search-message">{t('noMatches')}</p>
|
||||
)}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.type} className="catalog-search-group">
|
||||
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
|
||||
<ul className="catalog-search-list">
|
||||
{group.items.map((item) => {
|
||||
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
|
||||
const active = flatIndex === activeIndex;
|
||||
return (
|
||||
<li key={resultKey(item)}>
|
||||
<button
|
||||
type="button"
|
||||
id={`${listboxId}-opt-${flatIndex}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`catalog-search-option${active ? ' catalog-search-option-active' : ''}`}
|
||||
onMouseEnter={() => setActiveIndex(flatIndex)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => activate(item)}
|
||||
>
|
||||
{item.type === 'artist' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={portraitThumbUrl(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.movement_name, formatYears(item.birth_year, item.death_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'movement' && (
|
||||
<>
|
||||
<span
|
||||
className="catalog-search-movement-swatch"
|
||||
style={{ background: item.color || '#c9a96e' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.era_name, formatYears(item.start_year, item.end_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'painting' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={paintingThumbSrc(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.title}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.artist_name, item.year != null ? String(item.year) : null, item.movement_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatYears(start: number | null | undefined, end: number | null | undefined): string | null {
|
||||
if (start == null && end == null) return null;
|
||||
if (start != null && end != null) return `${start}–${end}`;
|
||||
if (start != null) return String(start);
|
||||
return String(end);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.curator-login-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.curator-login-modal {
|
||||
width: min(100%, 360px);
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.curator-login-modal h2 {
|
||||
margin: 0 0 8px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 20px;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-hint {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.curator-login-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.curator-login-field span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.curator-login-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e8d5b5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.curator-login-field input:focus {
|
||||
outline: 2px solid rgba(255, 220, 160, 0.45);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.curator-login-error {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #f0a0a0;
|
||||
}
|
||||
|
||||
.curator-login-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.curator-login-cancel,
|
||||
.curator-login-submit {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.curator-login-cancel {
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
background: transparent;
|
||||
color: rgba(201, 169, 110, 0.8);
|
||||
}
|
||||
|
||||
.curator-login-submit {
|
||||
border: 1px solid rgba(255, 220, 160, 0.45);
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-submit:disabled,
|
||||
.curator-login-cancel:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.curator-login-gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
}
|
||||
|
||||
.curator-login-gate h2 {
|
||||
margin: 0;
|
||||
font-family: 'Georgia', serif;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-gate p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.curator-login-gate-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CuratorLoginModal.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
const { t } = useTranslation('debug');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onLogin(username.trim(), password);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loginFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="curator-login-backdrop" onMouseDown={onClose}>
|
||||
<div
|
||||
className="curator-login-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="curator-login-title"
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="curator-login-title">{t('loginTitle')}</h2>
|
||||
<p className="curator-login-hint">
|
||||
Debug tools and catalog edits require a curator account.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="curator-login-field">
|
||||
<span>{t('username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="curator-login-field">
|
||||
<span>{t('password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="curator-login-error">{error}</p>}
|
||||
<div className="curator-login-actions">
|
||||
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
|
||||
{tc('cancel')}
|
||||
</button>
|
||||
<button type="submit" className="curator-login-submit" disabled={submitting}>
|
||||
{submitting ? t('saving') : t('signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
uploading: boolean;
|
||||
disabled?: boolean;
|
||||
onUploadPress?: () => void;
|
||||
onFileSelected: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function DebugUploadButton({
|
||||
uploading,
|
||||
disabled = false,
|
||||
onUploadPress,
|
||||
onFileSelected,
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inactive = disabled || uploading;
|
||||
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (!file || inactive) return;
|
||||
|
||||
try {
|
||||
await onFileSelected(file);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
|
||||
aria-busy={uploading}
|
||||
onClick={() => {
|
||||
if (!inactive) onUploadPress?.();
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="debug-upload-btn-loading">
|
||||
<span className="debug-upload-btn-spinner" aria-hidden />
|
||||
Uploading…
|
||||
</span>
|
||||
) : (
|
||||
'Upload'
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="debug-upload-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
|
||||
disabled={inactive}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.gallery-loading-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
color: rgba(232, 213, 181, 0.92);
|
||||
font-family: Georgia, serif;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-loading-marker p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(201, 169, 110, 0.22);
|
||||
border-top-color: #c9a96e;
|
||||
animation: gallery-loading-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(10, 10, 20, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-banner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 12;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 26, 0.88);
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-banner .gallery-loading-marker-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-compact {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes gallery-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import './GalleryLoadingMarker.css';
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
/** Full-area overlay (dims background). */
|
||||
overlay?: boolean;
|
||||
/** Compact strip along the bottom — does not block interaction. */
|
||||
banner?: boolean;
|
||||
/** Inline row for small panels (debug upload, etc.). */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GalleryLoadingMarker({
|
||||
message = 'Loading…',
|
||||
overlay = false,
|
||||
banner = false,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const modeClass = overlay
|
||||
? ' gallery-loading-marker-overlay'
|
||||
: banner
|
||||
? ' gallery-loading-marker-banner'
|
||||
: compact
|
||||
? ' gallery-loading-marker-compact'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gallery-loading-marker${modeClass}${className ? ` ${className}` : ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="gallery-loading-marker-spinner" aria-hidden />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.locale-switcher {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.locale-switcher-btn {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.locale-switcher-btn-active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.locale-switcher-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { setApiLocale } from '../api/client';
|
||||
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
|
||||
import './LocaleSwitcher.css';
|
||||
|
||||
interface Props {
|
||||
onLocaleChange?: (locale: AppLocale) => void;
|
||||
}
|
||||
|
||||
export default function LocaleSwitcher({ onLocaleChange }: Props) {
|
||||
const { t } = useTranslation('common');
|
||||
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
|
||||
|
||||
const setLocale = (locale: AppLocale) => {
|
||||
if (locale === current) return;
|
||||
void i18n.changeLanguage(locale);
|
||||
writeStoredLocale(locale);
|
||||
setApiLocale(locale);
|
||||
onLocaleChange?.(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="locale-switcher" role="group" aria-label={t('language')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
aria-pressed={current === 'en'}
|
||||
>
|
||||
{t('localeEn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
|
||||
onClick={() => setLocale('ru')}
|
||||
aria-pressed={current === 'ru'}
|
||||
>
|
||||
{t('localeRu')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,17 +87,39 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting.movements-flow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movement-branch-fast {
|
||||
stroke-opacity: 0.32;
|
||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||
}
|
||||
|
||||
.movement-stream-fast {
|
||||
stroke-opacity: 0.42;
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movement-stream-fill {
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movement-stream-core {
|
||||
@@ -107,6 +129,33 @@
|
||||
stroke-dasharray: 8 6;
|
||||
animation: stream-shimmer 16s linear infinite;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.movement-stream-fill.movement-stream-highlighted {
|
||||
filter: brightness(1.65) saturate(1.3);
|
||||
}
|
||||
|
||||
.movement-stream-core.movement-stream-highlighted {
|
||||
opacity: 0.72;
|
||||
filter: brightness(1.5);
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
|
||||
.movement-branch.movement-branch-highlighted {
|
||||
filter: brightness(1.55) saturate(1.2);
|
||||
}
|
||||
|
||||
@keyframes stream-shimmer {
|
||||
@@ -181,16 +230,23 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
position: absolute;
|
||||
transform: translate(-4px, -100%);
|
||||
max-width: 160px;
|
||||
padding: 2px 6px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.movement-flow-label-above {
|
||||
transform: translate(-4px, -100%);
|
||||
}
|
||||
|
||||
.movement-flow-label-below {
|
||||
transform: translate(-4px, 0);
|
||||
}
|
||||
|
||||
.movement-flow-label-btn {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
technique: 'Technique',
|
||||
composition: 'Composition',
|
||||
symbolism: 'Symbolism',
|
||||
history: 'History',
|
||||
subject: 'Subject',
|
||||
const CATEGORY_KEYS: Record<string, string> = {
|
||||
technique: 'categoryTechnique',
|
||||
composition: 'categoryComposition',
|
||||
symbolism: 'categorySymbolism',
|
||||
history: 'categoryHistorical',
|
||||
subject: 'categorySubject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const { t } = useTranslation('annotations');
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">Art history notes</h3>
|
||||
<h3 className="painting-annotations-title">{t('title')}</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const category = CATEGORY_LABELS[ann.category] || ann.category;
|
||||
const categoryKey = CATEGORY_KEYS[ann.category];
|
||||
const category = categoryKey ? t(categoryKey) : ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read source
|
||||
{t('readSource')}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -407,6 +407,40 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tour-stop-panel {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.tour-stop-panel h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.tour-stop-body {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: rgba(232, 213, 181, 0.92);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tour-stop-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.painting-description {
|
||||
max-width: 700px;
|
||||
margin-top: 20px;
|
||||
@@ -552,6 +586,48 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-upload-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-upload-btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-upload-btn-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.debug-upload-page-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.painting-frame-uploading,
|
||||
.bio-portrait-uploading {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.debug-image-panel.debug-image-panel-blocked {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-clear-btn {
|
||||
@@ -576,8 +652,7 @@
|
||||
border-color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-clear-btn:disabled,
|
||||
.debug-upload-btn:disabled {
|
||||
.debug-clear-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
|
||||
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
|
||||
import PaintingLightbox from './PaintingLightbox';
|
||||
import './PaintingDetail.css';
|
||||
import './GalleryLoadingMarker.css';
|
||||
|
||||
interface Props {
|
||||
data: PaintingDetail;
|
||||
artistPaintings?: Painting[];
|
||||
backLabel?: string;
|
||||
tourTitle?: string | null;
|
||||
tourText?: string | null;
|
||||
onBack: () => void;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
onCatalogNavigate: (paintingId: number) => void;
|
||||
@@ -130,6 +137,8 @@ function InfluenceCard({
|
||||
<img
|
||||
src={imageUrl(inf.artist_portrait)}
|
||||
alt={artistName}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
@@ -164,6 +173,8 @@ function InfluenceCard({
|
||||
<img
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
|
||||
alt={inf.title || 'Painting'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
@@ -184,6 +195,9 @@ function InfluenceCard({
|
||||
export default function PaintingDetailView({
|
||||
data,
|
||||
artistPaintings = [],
|
||||
backLabel = '← Back to Gallery',
|
||||
tourTitle = null,
|
||||
tourText = null,
|
||||
onBack,
|
||||
onPaintingClick,
|
||||
onCatalogNavigate,
|
||||
@@ -195,7 +209,9 @@ export default function PaintingDetailView({
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
onPaintingRemoved,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('painting');
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
const inTour = tourText != null;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [imageVersion, setImageVersion] = useState(0);
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
@@ -210,15 +226,13 @@ export default function PaintingDetailView({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseImageUrl = paintingImageUrl(painting);
|
||||
const imageSrc = baseImageUrl
|
||||
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
|
||||
: null;
|
||||
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
|
||||
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
|
||||
const displayImageSrc = uploading ? null : imageSrc;
|
||||
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
|
||||
|
||||
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||
@@ -239,6 +253,7 @@ export default function PaintingDetailView({
|
||||
setFixing(false);
|
||||
setClearing(false);
|
||||
setUploading(false);
|
||||
setUploadStatus(null);
|
||||
setMarkingChecked(false);
|
||||
setApplyingUrl(null);
|
||||
setMoreLoading(false);
|
||||
@@ -251,6 +266,9 @@ export default function PaintingDetailView({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -271,7 +289,7 @@ export default function PaintingDetailView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, painting.id, painting.title, painting.artist_name]);
|
||||
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
|
||||
|
||||
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
|
||||
setImageVersion((v) => v + 1);
|
||||
@@ -378,20 +396,27 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadPaintingImage(painting.id, file);
|
||||
await applyImageUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -433,8 +458,15 @@ export default function PaintingDetailView({
|
||||
|
||||
return (
|
||||
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
|
||||
{uploading && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="debug-upload-page-overlay"
|
||||
message={uploadStatus ?? 'Loading…'}
|
||||
/>
|
||||
)}
|
||||
<header className="painting-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back to Gallery</button>
|
||||
<button className="back-btn" onClick={onBack}>{backLabel}</button>
|
||||
<div className="painting-title-block">
|
||||
<h1>{painting.title}</h1>
|
||||
<p className="painting-meta">
|
||||
@@ -443,21 +475,23 @@ export default function PaintingDetailView({
|
||||
{showCatalogNav && (
|
||||
<span className="painting-catalog-position">
|
||||
{' · '}
|
||||
{catalogIndex + 1} of {artistPaintings.length}
|
||||
{inTour
|
||||
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
|
||||
: `${catalogIndex + 1} of ${artistPaintings.length}`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button className="bio-btn" onClick={onArtistBio}>
|
||||
About {painting.artist_name}
|
||||
{t('aboutArtist', { name: painting.artist_name })}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="painting-layout">
|
||||
<aside className="influence-panel influence-left">
|
||||
<h3>Influenced By</h3>
|
||||
<h3>{t('influencedBy')}</h3>
|
||||
{influencedBy.length === 0 ? (
|
||||
<p className="no-influences">No documented influences for this work.</p>
|
||||
<p className="no-influences">{t('noInfluences')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influencedBy.map((inf, index) => (
|
||||
@@ -490,12 +524,12 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
|
||||
role={imageSrc ? 'button' : undefined}
|
||||
tabIndex={imageSrc ? 0 : undefined}
|
||||
onClick={imageSrc ? () => setFullscreen(true) : undefined}
|
||||
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
|
||||
role={displayImageSrc ? 'button' : undefined}
|
||||
tabIndex={displayImageSrc ? 0 : undefined}
|
||||
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
|
||||
onKeyDown={
|
||||
imageSrc
|
||||
displayImageSrc
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -504,14 +538,14 @@ export default function PaintingDetailView({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={imageSrc ? 'View full screen' : undefined}
|
||||
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
title={displayImageSrc ? 'View full screen' : undefined}
|
||||
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
<div className="painting-frame-image-wrap">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
{displayImageSrc ? (
|
||||
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
{imageSrc && annotations.length > 0 && (
|
||||
{displayImageSrc && annotations.length > 0 && (
|
||||
<PaintingAnnotationMarkers
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
@@ -542,6 +576,16 @@ export default function PaintingDetailView({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inTour && (
|
||||
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
|
||||
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
|
||||
{tourText.trim() ? (
|
||||
<p className="tour-stop-body">{tourText}</p>
|
||||
) : (
|
||||
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{painting.description && (
|
||||
<div className="painting-description">
|
||||
<p>{painting.description}</p>
|
||||
@@ -550,9 +594,9 @@ export default function PaintingDetailView({
|
||||
</main>
|
||||
|
||||
<aside className="influence-panel influence-right">
|
||||
<h3>Influenced</h3>
|
||||
<h3>{t('influenced')}</h3>
|
||||
{influenced.length === 0 ? (
|
||||
<p className="no-influences">No documented works influenced by this painting yet.</p>
|
||||
<p className="no-influences">{t('noInfluencedWorks')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influenced.map((inf, index) => (
|
||||
@@ -569,14 +613,17 @@ export default function PaintingDetailView({
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" aria-label="Debug image search">
|
||||
<aside
|
||||
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||||
aria-label="Debug image search"
|
||||
>
|
||||
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
|
||||
<p className="debug-image-query">
|
||||
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
|
||||
</p>
|
||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||
{!debugLoading && debugSearch?.imageUrl && (
|
||||
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||||
{!uploading && !debugLoading && debugSearch?.imageUrl && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
@@ -589,7 +636,7 @@ export default function PaintingDetailView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No Google image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
@@ -597,7 +644,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
@@ -605,7 +652,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixImage}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -613,7 +660,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -627,20 +674,11 @@ export default function PaintingDetailView({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-danger">
|
||||
@@ -657,7 +695,7 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose painting image"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
@@ -667,9 +705,9 @@ export default function PaintingDetailView({
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
|
||||
{fullscreen && imageSrc && (
|
||||
{fullscreen && displayImageSrc && (
|
||||
<PaintingLightbox
|
||||
src={imageSrc}
|
||||
src={displayImageSrc}
|
||||
alt={painting.title}
|
||||
title={painting.title}
|
||||
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './Timeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
@@ -36,13 +40,39 @@ function formatYear(year: number): string {
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1;
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval;
|
||||
for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(Math.round(w));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerWidth),
|
||||
[span, containerWidth]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
.tours-popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tours-popup-modal {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(85vh, 640px);
|
||||
overflow: auto;
|
||||
padding: 20px 22px 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-popup-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tours-popup-header h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.25rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-popup-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tours-popup-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tours-popup-muted {
|
||||
margin: 0;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-popup-error {
|
||||
margin: 0 0 10px;
|
||||
color: #ffaaaa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-popup-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tours-popup-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tours-popup-card:hover {
|
||||
border-color: rgba(201, 169, 110, 0.55);
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
}
|
||||
|
||||
.tours-popup-cover {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tours-popup-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tours-popup-cover-empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.15), rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.tours-popup-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tours-popup-body strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tours-popup-body p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tours-popup-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, imageUrl } from '../api/client';
|
||||
import type { TourSummary } from '../types';
|
||||
import './ToursPopup.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectTour: (tourId: number) => void;
|
||||
}
|
||||
|
||||
export default function ToursPopup({ open, onClose, onSelectTour }: Props) {
|
||||
const { t } = useTranslation('tours');
|
||||
const [tours, setTours] = useState<TourSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.listPublishedTours()
|
||||
.then((data) => {
|
||||
if (!cancelled) setTours(data.tours);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
setTours([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="tours-popup-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="tours-popup-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tours-popup-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="tours-popup-header">
|
||||
<h2 id="tours-popup-title">{t('popupTitle')}</h2>
|
||||
<button type="button" className="tours-popup-close" onClick={onClose} aria-label={t('close')}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p className="tours-popup-hint">{t('popupHint')}</p>
|
||||
{loading && <p className="tours-popup-muted">{t('loading')}</p>}
|
||||
{error && <p className="tours-popup-error">{error}</p>}
|
||||
{!loading && !error && tours.length === 0 && (
|
||||
<p className="tours-popup-muted">{t('noPublished')}</p>
|
||||
)}
|
||||
<ul className="tours-popup-list">
|
||||
{tours.map((tour) => {
|
||||
const cover = tour.coverThumbnailPath || tour.coverImagePath;
|
||||
return (
|
||||
<li key={tour.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tours-popup-card"
|
||||
onClick={() => onSelectTour(tour.id)}
|
||||
>
|
||||
<div className="tours-popup-cover">
|
||||
{cover ? (
|
||||
<img src={imageUrl(cover)} alt="" />
|
||||
) : (
|
||||
<span className="tours-popup-cover-empty" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="tours-popup-body">
|
||||
<strong>{tour.title}</strong>
|
||||
{tour.description ? <p>{tour.description}</p> : null}
|
||||
<span className="tours-popup-meta">
|
||||
{t('stopCount', { count: tour.stopCount })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react';
|
||||
import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Text, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
@@ -9,8 +10,9 @@ import type {
|
||||
ArtistPeriod,
|
||||
ArtistNavigation,
|
||||
MovementArtistGroup,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
@@ -24,6 +26,36 @@ import {
|
||||
type MovementHallLayout,
|
||||
} from '../utils/movementHallLayout';
|
||||
import './VirtualGallery.css';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
|
||||
const GalleryTextureLoadContext = createContext<{
|
||||
begin: () => void;
|
||||
end: () => void;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
|
||||
* from unmounting the whole 3D scene and leaving a dark window.
|
||||
*/
|
||||
class SceneErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback?: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback ?? null;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface BaseGalleryProps {
|
||||
imageRevisions?: Record<number, number>;
|
||||
@@ -44,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps {
|
||||
data: MovementGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps;
|
||||
interface TourGalleryProps extends BaseGalleryProps {
|
||||
mode: 'tour';
|
||||
data: TourGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
@@ -66,9 +103,6 @@ const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 9;
|
||||
const ROW_GAP = 0.2;
|
||||
const WALL_PADDING = 1.4;
|
||||
/** Above this count, use a long corridor (short back wall, extended side walls). */
|
||||
const CORRIDOR_CATALOG_THRESHOLD = 15;
|
||||
const BACK_WALL_MAX_PAINTINGS = 8;
|
||||
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||
const MAX_WALL_ROWS = 1;
|
||||
const DOOR_WIDTH = 2.4;
|
||||
@@ -259,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
|
||||
return Math.max(MIN_HALL_SIZE, best);
|
||||
}
|
||||
|
||||
/** Left → right on each wall: later works on the left, earlier works on the right. */
|
||||
function orderPaintingsForWallDisplay(paintings: Painting[]) {
|
||||
return [...paintings].sort(comparePaintingsChronological).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit order along the side walls: first half on the left (starting at the
|
||||
* entrance, left of the opening view), second half on the right (ending at the
|
||||
* entrance). Back wall stays empty so first/last always sit on left/right.
|
||||
*/
|
||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
const back: Painting[] = [];
|
||||
const left: Painting[] = [];
|
||||
const right: Painting[] = [];
|
||||
|
||||
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
|
||||
sorted.forEach((p, i) => {
|
||||
if (i % 3 === 0) back.push(p);
|
||||
else if (i % 3 === 1) left.push(p);
|
||||
else right.push(p);
|
||||
});
|
||||
} else {
|
||||
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
|
||||
back.push(...sorted.slice(0, backCount));
|
||||
sorted.slice(backCount).forEach((p, i) => {
|
||||
if (i % 2 === 0) left.push(p);
|
||||
else right.push(p);
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = [...paintings].sort(comparePaintingsChronological);
|
||||
const mid = Math.ceil(ordered.length / 2);
|
||||
return [
|
||||
orderPaintingsForWallDisplay(back),
|
||||
orderPaintingsForWallDisplay(left),
|
||||
orderPaintingsForWallDisplay(right),
|
||||
[] as Painting[],
|
||||
ordered.slice(0, mid),
|
||||
ordered.slice(mid),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -346,12 +362,13 @@ function layoutWallSlots(
|
||||
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
|
||||
});
|
||||
} else if (side === 'left') {
|
||||
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: Math.PI / 2,
|
||||
side,
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
|
||||
});
|
||||
} else {
|
||||
slots.push({
|
||||
@@ -550,6 +567,7 @@ function CanvasCover({
|
||||
function usePaintingTexture(url: string | null) {
|
||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||
const [failed, setFailed] = useState(!url);
|
||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
@@ -561,11 +579,21 @@ function usePaintingTexture(url: string | null) {
|
||||
setFailed(false);
|
||||
let disposed = false;
|
||||
let loaded: THREE.Texture | null = null;
|
||||
let settled = false;
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
textureLoad?.end();
|
||||
};
|
||||
|
||||
textureLoad?.begin();
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
finish();
|
||||
if (disposed) {
|
||||
tex.dispose();
|
||||
return;
|
||||
@@ -583,16 +611,18 @@ function usePaintingTexture(url: string | null) {
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
finish();
|
||||
if (!disposed) setFailed(true);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
finish();
|
||||
loaded?.dispose();
|
||||
setTexture(null);
|
||||
};
|
||||
}, [url]);
|
||||
}, [url, textureLoad]);
|
||||
|
||||
return { texture, failed };
|
||||
}
|
||||
@@ -1488,6 +1518,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
||||
target.y = EYE_HEIGHT;
|
||||
}
|
||||
|
||||
function FrameloopSync({ active }: { active: boolean }) {
|
||||
const { invalidate } = useThree();
|
||||
useEffect(() => {
|
||||
if (active) invalidate();
|
||||
}, [active, invalidate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CameraController({
|
||||
position,
|
||||
target,
|
||||
@@ -1638,16 +1676,38 @@ export default function VirtualGallery(props: Props) {
|
||||
} = props;
|
||||
|
||||
const isMovement = props.mode === 'movement';
|
||||
const hallKey = isMovement ? props.data.movement.id : props.data.artist.id;
|
||||
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name;
|
||||
const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color;
|
||||
const isTour = props.mode === 'tour';
|
||||
const isWingedHall = isMovement || isTour;
|
||||
const hallKey = isTour
|
||||
? props.data.tour.id
|
||||
: isMovement
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id;
|
||||
const hallTitle = isTour
|
||||
? props.data.tour.title
|
||||
: isMovement
|
||||
? props.data.movement.name
|
||||
: props.data.artist.name;
|
||||
const movementColor = isTour
|
||||
? DEFAULT_MOVEMENT_COLOR
|
||||
: isMovement
|
||||
? props.data.movement.color
|
||||
: props.data.artist.movement_color;
|
||||
const initialPaintings = useMemo(() => {
|
||||
if (props.mode === 'movement') {
|
||||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||||
}
|
||||
return props.data.paintings;
|
||||
}, [props]);
|
||||
const initialPeriods = isMovement ? [] : props.data.periods;
|
||||
}, [
|
||||
props.mode,
|
||||
props.mode === 'tour'
|
||||
? props.data.tour.id
|
||||
: props.mode === 'movement'
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id,
|
||||
props.data.paintings,
|
||||
]);
|
||||
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
|
||||
|
||||
const [paintings, setPaintings] = useState(initialPaintings);
|
||||
const [periods, setPeriods] = useState(initialPeriods);
|
||||
@@ -1658,6 +1718,44 @@ export default function VirtualGallery(props: Props) {
|
||||
const [nearExit, setNearExit] = useState(false);
|
||||
const [nearPassage, setNearPassage] = useState(false);
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
const [texturesPending, setTexturesPending] = useState(0);
|
||||
const [canvasReady, setCanvasReady] = useState(false);
|
||||
const [glEpoch, setGlEpoch] = useState(0);
|
||||
const [glLost, setGlLost] = useState(false);
|
||||
|
||||
const textureLoad = useMemo(
|
||||
() => ({
|
||||
begin: () => setTexturesPending((n) => n + 1),
|
||||
end: () => setTexturesPending((n) => Math.max(0, n - 1)),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||||
const canvas = state.gl.domElement;
|
||||
// A freshly created canvas has a healthy context, so clear any lingering
|
||||
// "restoring" state from a previous loss/remount.
|
||||
setGlLost(false);
|
||||
setCanvasReady(true);
|
||||
const onLost = (event: Event) => {
|
||||
// Prevent the default so the browser can restore the context, and
|
||||
// force a clean remount to obtain a fresh WebGL context if it does not.
|
||||
event.preventDefault();
|
||||
setGlLost(true);
|
||||
window.setTimeout(() => {
|
||||
setGlLost((stillLost) => {
|
||||
if (stillLost) setGlEpoch((n) => n + 1);
|
||||
return stillLost;
|
||||
});
|
||||
}, 600);
|
||||
};
|
||||
const onRestored = () => {
|
||||
setGlLost(false);
|
||||
setGlEpoch((n) => n + 1);
|
||||
};
|
||||
canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
|
||||
canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPaintings(initialPaintings);
|
||||
@@ -1675,32 +1773,13 @@ export default function VirtualGallery(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const artistId = props.data.artist.id;
|
||||
(async () => {
|
||||
try {
|
||||
setSyncStatus('Syncing images…');
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||
await Promise.race([preloadArtistImages(artistId), timeout]);
|
||||
const fresh = await api.getArtist(artistId);
|
||||
if (!cancelled) {
|
||||
setPaintings(fresh.paintings);
|
||||
setPeriods(fresh.periods);
|
||||
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < fresh.paintings.length
|
||||
? `${withImg} of ${fresh.paintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setSyncStatus('');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
|
||||
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < initialPaintings.length
|
||||
? `${withImg} of ${initialPaintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}, [hallKey, props.mode, initialPaintings]);
|
||||
|
||||
const interiorStyle = useMemo(
|
||||
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
|
||||
@@ -1708,8 +1787,8 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const movementHalls = useMemo(
|
||||
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isMovement, paintings]
|
||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isWingedHall, paintings]
|
||||
);
|
||||
|
||||
const [hallIndex, setHallIndex] = useState(0);
|
||||
@@ -1719,18 +1798,26 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (isMovement && movementHalls.length > 0) {
|
||||
if (isWingedHall && movementHalls.length > 0) {
|
||||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||||
}
|
||||
return buildHallLayout(paintings, periods);
|
||||
}, [isMovement, movementHalls, hallIndex, paintings, periods]);
|
||||
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
||||
|
||||
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);
|
||||
|
||||
const galleryLoadingMessage = !canvasReady
|
||||
? 'Loading gallery…'
|
||||
: texturesPending > 0
|
||||
? 'Loading paintings…'
|
||||
: 'Loading gallery…';
|
||||
|
||||
const computedWindows = useMemo(() => {
|
||||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
||||
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
|
||||
}, [isMovement, interiorStyle, layout]);
|
||||
|
||||
const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
|
||||
const halfW = layout.width / 2 - 0.55;
|
||||
const halfD = layout.depth / 2 - 0.35;
|
||||
@@ -1790,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey, initialPos, initialTarget]);
|
||||
|
||||
const openExitNav = useCallback(async () => {
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
setShowExitNav(true);
|
||||
return;
|
||||
}
|
||||
@@ -1804,9 +1891,9 @@ export default function VirtualGallery(props: Props) {
|
||||
} finally {
|
||||
setNavLoading(false);
|
||||
}
|
||||
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]);
|
||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||
|
||||
const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
||||
|
||||
const moveCamera = useCallback(
|
||||
@@ -1831,7 +1918,7 @@ export default function VirtualGallery(props: Props) {
|
||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
||||
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(halfD, target.z));
|
||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
@@ -1851,7 +1938,7 @@ export default function VirtualGallery(props: Props) {
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
},
|
||||
[halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall]
|
||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1868,7 +1955,7 @@ export default function VirtualGallery(props: Props) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
keysPressed.current.add(e.key);
|
||||
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
|
||||
if (isMovement && nearPassage && hasNextHall) {
|
||||
if (isWingedHall && nearPassage && hasNextHall) {
|
||||
goToNextHall();
|
||||
} else {
|
||||
openExitNav();
|
||||
@@ -1896,15 +1983,19 @@ export default function VirtualGallery(props: Props) {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]);
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
|
||||
|
||||
const handleNavigate = (artistId: number) => {
|
||||
if (isMovement) return;
|
||||
if (isWingedHall) return;
|
||||
setShowExitNav(false);
|
||||
props.onNavigateArtist(artistId);
|
||||
};
|
||||
|
||||
const subtitle = isMovement
|
||||
const subtitle = isTour
|
||||
? `Guided tour · ${paintings.length} works${
|
||||
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
|
||||
}`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${interiorStyle.subtitle} · ${paintings.length} works${
|
||||
movementHalls.length > 1
|
||||
@@ -1950,7 +2041,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const exitHint = isMovement ? (
|
||||
const exitHint = isWingedHall ? (
|
||||
<>
|
||||
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
|
||||
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
|
||||
@@ -1960,11 +2051,15 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const hallSubtitle =
|
||||
isMovement && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}`
|
||||
isWingedHall && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length}${
|
||||
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
|
||||
}`
|
||||
: undefined;
|
||||
|
||||
const instructionsTitle = isMovement
|
||||
const instructionsTitle = isTour
|
||||
? `${hallTitle} · Guided tour`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${hallTitle} · ${interiorStyle.label}`
|
||||
: `${hallTitle} Gallery`
|
||||
@@ -1980,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
|
||||
</div>
|
||||
<div className="gallery-header-meta">
|
||||
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
|
||||
{isMovement ? 'Wings / Exit →' : 'Exit →'}
|
||||
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
|
||||
</button>
|
||||
{!isMovement && (
|
||||
{!isWingedHall && (
|
||||
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1996,7 +2091,13 @@ export default function VirtualGallery(props: Props) {
|
||||
onPointerLeave={endCanvasDrag}
|
||||
onPointerCancel={endCanvasDrag}
|
||||
>
|
||||
{syncStatus && (
|
||||
{active && (gallerySceneLoading || glLost) && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
|
||||
/>
|
||||
)}
|
||||
{syncStatus && !gallerySceneLoading && !glLost && (
|
||||
<div className="gallery-loading-overlay gallery-sync-badge">
|
||||
<p>{syncStatus}</p>
|
||||
</div>
|
||||
@@ -2005,10 +2106,14 @@ export default function VirtualGallery(props: Props) {
|
||||
<div className="gallery-exit-hint">{exitHint}</div>
|
||||
)}
|
||||
<Canvas
|
||||
key={`${hallKey}-${glEpoch}`}
|
||||
shadows
|
||||
frameloop={active ? 'always' : 'never'}
|
||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
<FrameloopSync active={active} />
|
||||
<color attach="background" args={[sceneBackground]} />
|
||||
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
||||
<ambientLight intensity={ambientIntensity} />
|
||||
@@ -2017,36 +2122,40 @@ export default function VirtualGallery(props: Props) {
|
||||
intensity={interiorStyle ? 0.85 : 0.65}
|
||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
<ArtistHall
|
||||
<SceneErrorBoundary fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||
<ArtistHall
|
||||
layout={layout}
|
||||
hallTitle={hallTitle}
|
||||
hallSubtitle={hallSubtitle}
|
||||
movementColor={movementColor}
|
||||
interiorStyle={interiorStyle}
|
||||
imageRevisions={imageRevisions}
|
||||
showCaptions={isMovement}
|
||||
showCaptions={isWingedHall}
|
||||
onPaintingClick={onPaintingClick}
|
||||
onExitActivate={openExitNav}
|
||||
nearExit={nearExit}
|
||||
movementMode={isMovement}
|
||||
movementMode={isWingedHall}
|
||||
computedWindows={computedWindows}
|
||||
hasNextHall={hasNextHall}
|
||||
onNextHall={goToNextHall}
|
||||
nearPassage={nearPassage}
|
||||
/>
|
||||
</GalleryTextureLoadContext.Provider>
|
||||
<CameraController position={camPos} target={camTarget} />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{!isMovement && showExitNav && (
|
||||
{!isWingedHall && showExitNav && (
|
||||
<NavigationPanel
|
||||
navigation={navigation}
|
||||
loading={navLoading}
|
||||
@@ -2055,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMovement && showExitNav && (
|
||||
{isWingedHall && showExitNav && (
|
||||
<MovementHallNavPanel
|
||||
movementName={hallTitle}
|
||||
halls={movementHalls}
|
||||
@@ -2083,8 +2192,8 @@ export default function VirtualGallery(props: Props) {
|
||||
<li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
|
||||
<li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
|
||||
<li>Drag on the view to look around</li>
|
||||
<li>Click a painting to view details and influences</li>
|
||||
{isMovement ? (
|
||||
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
|
||||
{isWingedHall ? (
|
||||
<>
|
||||
<li>Date and artist labels appear below each frame</li>
|
||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
|
||||
|
||||
interface AuthContextValue {
|
||||
role: AuthRole;
|
||||
username?: string;
|
||||
isCurator: boolean;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<AuthRole>('user');
|
||||
const [username, setUsername] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const me = await getAuthMe();
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (user: string, password: string) => {
|
||||
const me = await loginCurator(user, password);
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutCurator();
|
||||
setRole('user');
|
||||
setUsername(undefined);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
role,
|
||||
username,
|
||||
isCurator: role === 'curator',
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[role, username, loading, login, logout, refresh]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_CONCURRENT = 6;
|
||||
const queue: Array<() => void> = [];
|
||||
let inFlight = 0;
|
||||
|
||||
function pumpQueue() {
|
||||
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueLoad(run: () => void) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const task = () => {
|
||||
inFlight++;
|
||||
run();
|
||||
resolve();
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
};
|
||||
queue.push(task);
|
||||
pumpQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits parallel image URL activation so hundreds of timeline portraits
|
||||
* do not saturate the browser connection pool at once.
|
||||
*/
|
||||
export function useQueuedImageSrc(src: string | null | undefined): string | undefined {
|
||||
const [activeSrc, setActiveSrc] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setActiveSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setActiveSrc(undefined);
|
||||
|
||||
enqueueLoad(() => {
|
||||
if (!cancelled) setActiveSrc(src);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return activeSrc;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { readStoredLocale, writeStoredLocale } from '../utils/localeStorage';
|
||||
|
||||
import enCommon from '../locales/en/common.json';
|
||||
import enHome from '../locales/en/home.json';
|
||||
import enSearch from '../locales/en/search.json';
|
||||
import enGallery from '../locales/en/gallery.json';
|
||||
import enPainting from '../locales/en/painting.json';
|
||||
import enBio from '../locales/en/bio.json';
|
||||
import enAnnotations from '../locales/en/annotations.json';
|
||||
import enDebug from '../locales/en/debug.json';
|
||||
import enTranslations from '../locales/en/translations.json';
|
||||
import enInfluences from '../locales/en/influences.json';
|
||||
import enTours from '../locales/en/tours.json';
|
||||
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
import ruSearch from '../locales/ru/search.json';
|
||||
import ruGallery from '../locales/ru/gallery.json';
|
||||
import ruPainting from '../locales/ru/painting.json';
|
||||
import ruBio from '../locales/ru/bio.json';
|
||||
import ruAnnotations from '../locales/ru/annotations.json';
|
||||
import ruDebug from '../locales/ru/debug.json';
|
||||
import ruTranslations from '../locales/ru/translations.json';
|
||||
import ruInfluences from '../locales/ru/influences.json';
|
||||
import ruTours from '../locales/ru/tours.json';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
home: enHome,
|
||||
search: enSearch,
|
||||
gallery: enGallery,
|
||||
painting: enPainting,
|
||||
bio: enBio,
|
||||
annotations: enAnnotations,
|
||||
debug: enDebug,
|
||||
translations: enTranslations,
|
||||
influences: enInfluences,
|
||||
tours: enTours,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
home: ruHome,
|
||||
search: ruSearch,
|
||||
gallery: ruGallery,
|
||||
painting: ruPainting,
|
||||
bio: ruBio,
|
||||
annotations: ruAnnotations,
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
tours: ruTours,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Art history notes",
|
||||
"readSource": "Read source",
|
||||
"categoryTechnique": "Technique",
|
||||
"categoryComposition": "Composition",
|
||||
"categorySubject": "Subject",
|
||||
"categorySymbolism": "Symbolism",
|
||||
"categoryHistorical": "Historical context",
|
||||
"categoryOther": "Notes"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Enter Gallery",
|
||||
"noBio": "No biography available yet.",
|
||||
"wikipediaSource": "Text adapted from Wikipedia",
|
||||
"backToTimeline": "← Back to Timeline"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Loading…",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"error": "Something went wrong",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Checked",
|
||||
"fixIt": "Fix it",
|
||||
"more": "More",
|
||||
"clear": "Clear",
|
||||
"upload": "Upload",
|
||||
"searching": "Searching…",
|
||||
"saving": "Saving…",
|
||||
"loginTitle": "Curator login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"loginFailed": "Login failed"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"exitToTimeline": "Exit to Timeline",
|
||||
"loadingGallery": "Loading gallery…",
|
||||
"instructionsTitle": "Gallery controls",
|
||||
"instructionMove": "Drag to look around",
|
||||
"instructionZoom": "Scroll to zoom",
|
||||
"instructionClick": "Click a painting to view details",
|
||||
"wingOf": "Wing {{current}} of {{total}}",
|
||||
"exitConfirmTitle": "Leave gallery?",
|
||||
"exitConfirmBody": "Return to the timeline or stay in the hall.",
|
||||
"stay": "Stay",
|
||||
"exit": "Exit"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "Virtual Art Gallery",
|
||||
"subtitle": "Explore art history on an interactive timeline — enter 3D galleries and discover connections between artists and masterpieces.",
|
||||
"loadingArtHistory": "Loading art history…",
|
||||
"loadingPortraits": "Loading portraits…",
|
||||
"openingArtistGallery": "Opening artist gallery…",
|
||||
"openingMovementGallery": "Opening movement gallery…",
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"backToGallery": "← Back to Gallery",
|
||||
"curatorLogin": "Curator login",
|
||||
"curatorLogout": "Log out",
|
||||
"signedInAs": "Signed in as {{username}}",
|
||||
"debugMode": "Debug mode",
|
||||
"showMoreDebug": "Show more (debug)",
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
"curatorRequiredTitle": "Curator access required",
|
||||
"curatorRequiredBody": "Sign in as a curator to use this tool.",
|
||||
"backToGalleryBtn": "Back to gallery"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"tabGraph": "Graph",
|
||||
"loadFailed": "Failed to load influences",
|
||||
"searchPlaceholder": "Search artist, painting, source…",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading…",
|
||||
"loadingParse": "Reading file…",
|
||||
"loadingPreview": "Validating influence links…",
|
||||
"loadingCommit": "Importing links into the database…",
|
||||
"loadingSave": "Saving link…",
|
||||
"addEdge": "Add link",
|
||||
"cancelAdd": "Cancel",
|
||||
"total": "{{count}} links",
|
||||
"filtered": "filtered by artist",
|
||||
"clearFilter": "Clear artist filter",
|
||||
"subjectPainting": "Subject painting",
|
||||
"searchPainting": "Search painting…",
|
||||
"sourceType": "Source type",
|
||||
"typeArtist": "Artist",
|
||||
"typePainting": "Painting",
|
||||
"typeMovement": "Movement",
|
||||
"sourceEntity": "Source entity",
|
||||
"searchSource": "Search source…",
|
||||
"notes": "Notes",
|
||||
"saveEdge": "Save link",
|
||||
"addRequiresIds": "Pick a subject painting and a source entity",
|
||||
"colSubject": "Subject",
|
||||
"colSource": "Source",
|
||||
"colType": "Type",
|
||||
"colNotes": "Notes",
|
||||
"colActions": "Actions",
|
||||
"colAction": "Action",
|
||||
"colDirection": "Direction",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this influence link?",
|
||||
"noEdges": "No influence links match.",
|
||||
"stepUpload": "1. Upload",
|
||||
"stepMapping": "2. Map columns",
|
||||
"stepPreview": "3. Validate",
|
||||
"stepDone": "4. Done",
|
||||
"uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Failed to parse file",
|
||||
"previewFailed": "Failed to build preview",
|
||||
"commitFailed": "Failed to commit import",
|
||||
"rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).",
|
||||
"fileInfo": "{{name}} · {{rows}} rows · {{format}}",
|
||||
"sheet": "Sheet",
|
||||
"preset": "Mapping preset",
|
||||
"column": "Column",
|
||||
"role": "Role",
|
||||
"sample": "Sample",
|
||||
"backStep": "Back",
|
||||
"runPreview": "Validate & preview",
|
||||
"previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}",
|
||||
"warnings": "{{count}} warnings",
|
||||
"commitImport": "Import {{count}} links",
|
||||
"commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).",
|
||||
"alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.",
|
||||
"alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.",
|
||||
"forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)",
|
||||
"matchFile": "same file bytes",
|
||||
"matchData": "same mapped data",
|
||||
"searchArtist": "Search artist for neighborhood graph…",
|
||||
"graphEmpty": "Select an artist to visualize influence neighbors.",
|
||||
"graphStats": "{{nodes}} nodes · {{edges}} edges"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"aboutArtist": "About {{name}}",
|
||||
"influencedBy": "Influenced By",
|
||||
"influenced": "Influenced",
|
||||
"noInfluences": "No documented influences for this work.",
|
||||
"noInfluencedWorks": "No documented works influenced by this painting yet.",
|
||||
"catalogPosition": "Catalog position",
|
||||
"lightboxHint": "Click anywhere to close",
|
||||
"prevPainting": "Previous painting",
|
||||
"nextPainting": "Next painting",
|
||||
"tourNotes": "Tour notes",
|
||||
"tourNotesFor": "Tour notes · {{title}}",
|
||||
"tourNotesEmpty": "No notes for this stop.",
|
||||
"tourStopPosition": "Stop {{current}} of {{total}}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Search",
|
||||
"placeholder": "Search artists, paintings, movements…",
|
||||
"resultsLabel": "Search results",
|
||||
"noMatches": "No matches found.",
|
||||
"groupArtist": "Artists",
|
||||
"groupMovement": "Movements",
|
||||
"groupPainting": "Paintings",
|
||||
"searchFailed": "Search failed"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Guided tours",
|
||||
"popupTitle": "Guided tours",
|
||||
"popupHint": "Choose a curated walkthrough of selected works.",
|
||||
"close": "Close",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Failed to load tours",
|
||||
"noPublished": "No published tours yet.",
|
||||
"noTours": "No tours yet. Create one to get started.",
|
||||
"stopCount": "{{count}} stops",
|
||||
"back": "← Back to gallery",
|
||||
"create": "Create",
|
||||
"newTourPlaceholder": "New tour title…",
|
||||
"selectTour": "Select a tour to edit.",
|
||||
"tourTitle": "Title",
|
||||
"tourDescription": "Description",
|
||||
"status": "Status",
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"saveMeta": "Save details",
|
||||
"delete": "Delete tour",
|
||||
"confirmDelete": "Delete this tour and all its stops?",
|
||||
"searchPainting": "Search paintings to add…",
|
||||
"saveStops": "Save stops",
|
||||
"remove": "Remove",
|
||||
"stopBodyPlaceholder": "Tour notes for this stop (English)…",
|
||||
"noStops": "No stops yet. Search and add paintings."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Translation review",
|
||||
"back": "← Back to gallery",
|
||||
"locale": "Locale",
|
||||
"entityType": "Entity type",
|
||||
"status": "Status",
|
||||
"all": "All",
|
||||
"draft": "Draft",
|
||||
"reviewed": "Reviewed",
|
||||
"published": "Published",
|
||||
"coverage": "Coverage",
|
||||
"artistsBio": "Artists with bio (ru)",
|
||||
"paintingsTitle": "Paintings with title alias (ru)",
|
||||
"canonical": "English (canonical)",
|
||||
"translation": "Translation",
|
||||
"saveDraft": "Save draft",
|
||||
"publish": "Publish",
|
||||
"noRows": "No translation rows match the filter.",
|
||||
"loadFailed": "Failed to load translations"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Войти в галерею",
|
||||
"noBio": "Биография пока недоступна.",
|
||||
"wikipediaSource": "Текст адаптирован из Википедии",
|
||||
"backToTimeline": "← На шкалу времени"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Загрузка…",
|
||||
"close": "Закрыть",
|
||||
"back": "Назад",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"error": "Что-то пошло не так",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Язык"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Проверено",
|
||||
"fixIt": "Исправить",
|
||||
"more": "Ещё",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить",
|
||||
"searching": "Поиск…",
|
||||
"saving": "Сохранение…",
|
||||
"loginTitle": "Вход куратора",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"loginFailed": "Ошибка входа"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"exitToTimeline": "Выход на шкалу времени",
|
||||
"loadingGallery": "Загрузка галереи…",
|
||||
"instructionsTitle": "Управление",
|
||||
"instructionMove": "Перетаскивайте для обзора",
|
||||
"instructionZoom": "Колёсико — масштаб",
|
||||
"instructionClick": "Нажмите на картину для подробностей",
|
||||
"wingOf": "Крыло {{current}} из {{total}}",
|
||||
"exitConfirmTitle": "Покинуть галерею?",
|
||||
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
|
||||
"stay": "Остаться",
|
||||
"exit": "Выход"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"tabGraph": "Граф",
|
||||
"loadFailed": "Не удалось загрузить связи",
|
||||
"searchPlaceholder": "Поиск: художник, картина, источник…",
|
||||
"refresh": "Обновить",
|
||||
"loading": "Загрузка…",
|
||||
"loadingParse": "Чтение файла…",
|
||||
"loadingPreview": "Проверка связей влияния…",
|
||||
"loadingCommit": "Импорт связей в базу…",
|
||||
"loadingSave": "Сохранение связи…",
|
||||
"addEdge": "Добавить связь",
|
||||
"cancelAdd": "Отмена",
|
||||
"total": "{{count}} связей",
|
||||
"filtered": "фильтр по художнику",
|
||||
"clearFilter": "Сбросить фильтр",
|
||||
"subjectPainting": "Картина (субъект)",
|
||||
"searchPainting": "Поиск картины…",
|
||||
"sourceType": "Тип источника",
|
||||
"typeArtist": "Художник",
|
||||
"typePainting": "Картина",
|
||||
"typeMovement": "Направление",
|
||||
"sourceEntity": "Источник",
|
||||
"searchSource": "Поиск источника…",
|
||||
"notes": "Заметки",
|
||||
"saveEdge": "Сохранить связь",
|
||||
"addRequiresIds": "Выберите картину и источник",
|
||||
"colSubject": "Субъект",
|
||||
"colSource": "Источник",
|
||||
"colType": "Тип",
|
||||
"colNotes": "Заметки",
|
||||
"colActions": "Действия",
|
||||
"colAction": "Действие",
|
||||
"colDirection": "Направление",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить эту связь влияния?",
|
||||
"noEdges": "Связи не найдены.",
|
||||
"stepUpload": "1. Файл",
|
||||
"stepMapping": "2. Столбцы",
|
||||
"stepPreview": "3. Проверка",
|
||||
"stepDone": "4. Готово",
|
||||
"uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Не удалось разобрать файл",
|
||||
"previewFailed": "Не удалось построить превью",
|
||||
"commitFailed": "Не удалось выполнить импорт",
|
||||
"rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.",
|
||||
"fileInfo": "{{name}} · {{rows}} строк · {{format}}",
|
||||
"sheet": "Лист",
|
||||
"preset": "Шаблон сопоставления",
|
||||
"column": "Столбец",
|
||||
"role": "Роль",
|
||||
"sample": "Пример",
|
||||
"backStep": "Назад",
|
||||
"runPreview": "Проверить",
|
||||
"previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}",
|
||||
"warnings": "{{count}} предупреждений",
|
||||
"commitImport": "Импортировать {{count}} связей",
|
||||
"commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).",
|
||||
"alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.",
|
||||
"alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.",
|
||||
"forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)",
|
||||
"matchFile": "те же байты файла",
|
||||
"matchData": "те же сопоставленные данные",
|
||||
"searchArtist": "Поиск художника для графа…",
|
||||
"graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.",
|
||||
"graphStats": "{{nodes}} узлов · {{edges}} рёбер"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"aboutArtist": "О {{name}}",
|
||||
"influencedBy": "Под влиянием",
|
||||
"influenced": "Влияние на",
|
||||
"noInfluences": "Для этой работы нет задокументированных влияний.",
|
||||
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
|
||||
"catalogPosition": "Позиция в каталоге",
|
||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||
"prevPainting": "Предыдущая картина",
|
||||
"nextPainting": "Следующая картина",
|
||||
"tourNotes": "Текст экскурсии",
|
||||
"tourNotesFor": "Экскурсия · {{title}}",
|
||||
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Поиск",
|
||||
"placeholder": "Художники, картины, направления…",
|
||||
"resultsLabel": "Результаты поиска",
|
||||
"noMatches": "Ничего не найдено.",
|
||||
"groupArtist": "Художники",
|
||||
"groupMovement": "Направления",
|
||||
"groupPainting": "Картины",
|
||||
"searchFailed": "Ошибка поиска"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Экскурсии",
|
||||
"popupTitle": "Экскурсии",
|
||||
"popupHint": "Выберите кураторскую подборку произведений.",
|
||||
"close": "Закрыть",
|
||||
"loading": "Загрузка…",
|
||||
"loadFailed": "Не удалось загрузить экскурсии",
|
||||
"noPublished": "Пока нет опубликованных экскурсий.",
|
||||
"noTours": "Экскурсий пока нет. Создайте первую.",
|
||||
"stopCount": "{{count}} остановок",
|
||||
"back": "← В галерею",
|
||||
"create": "Создать",
|
||||
"newTourPlaceholder": "Название новой экскурсии…",
|
||||
"selectTour": "Выберите экскурсию для редактирования.",
|
||||
"tourTitle": "Название",
|
||||
"tourDescription": "Описание",
|
||||
"status": "Статус",
|
||||
"draft": "Черновик",
|
||||
"published": "Опубликовано",
|
||||
"saveMeta": "Сохранить сведения",
|
||||
"delete": "Удалить экскурсию",
|
||||
"confirmDelete": "Удалить эту экскурсию и все остановки?",
|
||||
"searchPainting": "Поиск картин для добавления…",
|
||||
"saveStops": "Сохранить остановки",
|
||||
"remove": "Убрать",
|
||||
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
|
||||
"noStops": "Остановок пока нет. Найдите и добавьте картины."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Проверка переводов",
|
||||
"back": "← В галерею",
|
||||
"locale": "Язык",
|
||||
"entityType": "Тип объекта",
|
||||
"status": "Статус",
|
||||
"all": "Все",
|
||||
"draft": "Черновик",
|
||||
"reviewed": "Проверено",
|
||||
"published": "Опубликовано",
|
||||
"coverage": "Охват",
|
||||
"artistsBio": "Художники с биографией (ru)",
|
||||
"paintingsTitle": "Картины с русским названием",
|
||||
"canonical": "Английский (оригинал)",
|
||||
"translation": "Перевод",
|
||||
"saveDraft": "Сохранить черновик",
|
||||
"publish": "Опубликовать",
|
||||
"noRows": "Нет строк по выбранному фильтру.",
|
||||
"loadFailed": "Не удалось загрузить переводы"
|
||||
}
|
||||
+5
-1
@@ -1,10 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './context/AuthContext.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -27,10 +27,20 @@
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-session-active {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-overlay {
|
||||
@@ -44,6 +54,8 @@
|
||||
padding: 24px 16px 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 110;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-dev-tools {
|
||||
@@ -56,7 +68,9 @@
|
||||
}
|
||||
|
||||
.debug-mode-toggle,
|
||||
.checkup-link-btn {
|
||||
.checkup-link-btn,
|
||||
.curator-login-btn,
|
||||
.curator-logout-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
@@ -69,7 +83,9 @@
|
||||
}
|
||||
|
||||
.debug-mode-toggle:hover,
|
||||
.checkup-link-btn:hover {
|
||||
.checkup-link-btn:hover,
|
||||
.curator-login-btn:hover,
|
||||
.curator-logout-btn:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
@@ -116,6 +132,25 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.curator-session-label {
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.curator-login-btn {
|
||||
border-color: rgba(255, 220, 160, 0.35);
|
||||
}
|
||||
|
||||
.curator-logout-btn {
|
||||
border-color: rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
}
|
||||
|
||||
.checkup-link-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
+571
-114
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
@@ -6,8 +7,33 @@ import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import ToursPage from '../pages/ToursPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import ToursPopup from '../components/ToursPopup';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import '../components/ToursPopup.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import '../pages/ToursPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type {
|
||||
TimelineData,
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
Painting,
|
||||
PaintingDetail,
|
||||
MovementGalleryDetail,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
|
||||
import './HomePage.css';
|
||||
@@ -15,14 +41,21 @@ import './HomePage.css';
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
| { type: 'tours' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||
|
||||
type GallerySession =
|
||||
| { kind: 'artist'; artistId: number; data: ArtistDetail }
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
@@ -35,6 +68,17 @@ function patchPaintingInMovementDetail(
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInTourDetail(
|
||||
detail: TourGalleryDetail,
|
||||
paintingId: number,
|
||||
patch: Partial<Painting>
|
||||
): TourGalleryDetail {
|
||||
return {
|
||||
...detail,
|
||||
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInArtistDetail(
|
||||
detail: ArtistDetail,
|
||||
paintingId: number,
|
||||
@@ -56,7 +100,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
|
||||
function patchReturnToAfterRemove(
|
||||
returnTo: View,
|
||||
freshArtist?: ArtistDetail,
|
||||
freshMovement?: MovementGalleryDetail
|
||||
freshMovement?: MovementGalleryDetail,
|
||||
freshTour?: TourGalleryDetail
|
||||
): View {
|
||||
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
|
||||
return { ...returnTo, data: freshArtist };
|
||||
@@ -68,8 +113,14 @@ function patchReturnToAfterRemove(
|
||||
) {
|
||||
return { ...returnTo, data: freshMovement };
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
|
||||
return { ...returnTo, data: freshTour };
|
||||
}
|
||||
if (returnTo.type === 'painting') {
|
||||
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
|
||||
return {
|
||||
...returnTo,
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'bio') {
|
||||
const data =
|
||||
@@ -77,7 +128,7 @@ function patchReturnToAfterRemove(
|
||||
return {
|
||||
...returnTo,
|
||||
data,
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
|
||||
};
|
||||
}
|
||||
return returnTo;
|
||||
@@ -95,6 +146,8 @@ function catalogNavigateTarget(
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout } = useAuth();
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
@@ -103,12 +156,19 @@ export default function HomePage() {
|
||||
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
const [galleryEntryLoading, setGalleryEntryLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
||||
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
@@ -119,53 +179,76 @@ export default function HomePage() {
|
||||
} | null>(null);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
useEffect(() => {
|
||||
api.getBounds()
|
||||
.then((b) => {
|
||||
const min = b.min_year ?? -800;
|
||||
const max = b.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type === 'gallery') {
|
||||
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
|
||||
} else if (view.type === 'movement-gallery') {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'tour-gallery') {
|
||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
const loadTimelineData = useCallback(async (start: number, end: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [timeline, artistList] = await Promise.all([
|
||||
api.getTimeline(start, end),
|
||||
api.getArtists(start, end),
|
||||
]);
|
||||
setTimelineData(timeline);
|
||||
setArtists(artistList);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Load full catalog once — pan/zoom filters client-side (MovementBands, Timeline).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const catalog = await api.getCatalogBootstrap();
|
||||
if (cancelled) return;
|
||||
|
||||
const min = catalog.bounds.min_year ?? -800;
|
||||
const max = catalog.bounds.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
|
||||
setArtists(catalog.artists);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!cancelled) setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localeVersion]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
|
||||
setApiLocale(locale);
|
||||
setLocaleVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimelineData(viewStart, viewEnd);
|
||||
}, [viewStart, viewEnd, loadTimelineData]);
|
||||
const viewChangeScheduler = useRef(
|
||||
createViewChangeScheduler((start, end) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
})
|
||||
);
|
||||
|
||||
const handleViewChange = (start: number, end: number) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
};
|
||||
useEffect(() => () => viewChangeScheduler.current.cancel(), []);
|
||||
|
||||
const handleViewChange = useCallback((start: number, end: number) => {
|
||||
viewChangeScheduler.current.schedule(start, end);
|
||||
}, []);
|
||||
|
||||
const goToTimelineHome = useCallback(() => {
|
||||
detailReturnToRef.current = { type: 'timeline' };
|
||||
setGallerySession(null);
|
||||
setHoveredLifespan(null);
|
||||
setGalleryEntryLoading(null);
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
@@ -180,11 +263,79 @@ export default function HomePage() {
|
||||
writeDebugShowMore(enabled);
|
||||
};
|
||||
|
||||
const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
|
||||
setLoginRedirect(redirect);
|
||||
setLoginOpen(true);
|
||||
};
|
||||
|
||||
const handleCuratorLogin = async (user: string, password: string) => {
|
||||
await login(user, password);
|
||||
setLoginOpen(false);
|
||||
if (loginRedirect === 'checkup') {
|
||||
setView({ type: 'checkup' });
|
||||
} else if (loginRedirect === 'translations') {
|
||||
setView({ type: 'translations' });
|
||||
} else if (loginRedirect === 'influences') {
|
||||
setView({ type: 'influences' });
|
||||
} else if (loginRedirect === 'tours') {
|
||||
setView({ type: 'tours' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
|
||||
const handleCuratorLogout = async () => {
|
||||
await logout();
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (
|
||||
view.type === 'checkup' ||
|
||||
view.type === 'translations' ||
|
||||
view.type === 'influences' ||
|
||||
view.type === 'tours'
|
||||
) {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
|
||||
const openCheckup = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('checkup');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'checkup' });
|
||||
};
|
||||
|
||||
const openTranslations = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('translations');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'translations' });
|
||||
};
|
||||
|
||||
const openInfluences = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('influences');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'influences' });
|
||||
};
|
||||
|
||||
const openToursEditor = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('tours');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'tours' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
image_path: fixResult.imagePath,
|
||||
thumbnail_path: fixResult.thumbnailPath,
|
||||
image_cache_key: fixResult.image_cache_key ?? null,
|
||||
thumbnail_cache_key: fixResult.thumbnail_cache_key ?? null,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
@@ -210,6 +361,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -224,6 +381,9 @@ export default function HomePage() {
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'tour') {
|
||||
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}, []);
|
||||
@@ -255,6 +415,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -269,6 +435,9 @@ export default function HomePage() {
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'tour') {
|
||||
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
},
|
||||
@@ -312,6 +481,9 @@ export default function HomePage() {
|
||||
const data = await api.getArtist(artistId);
|
||||
const patch: Partial<Artist> = {
|
||||
portrait_path: fixResult.portraitPath,
|
||||
portrait_thumb_path: fixResult.portraitThumbPath ?? null,
|
||||
portrait_cache_key: fixResult.portrait_cache_key ?? null,
|
||||
portrait_thumb_cache_key: fixResult.portrait_thumb_cache_key ?? null,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
@@ -342,23 +514,66 @@ export default function HomePage() {
|
||||
[applyArtistPatch]
|
||||
);
|
||||
|
||||
const openArtistGallery = useCallback((artistId: number, data: ArtistDetail) => {
|
||||
const session: GallerySession = { kind: 'artist', artistId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
}, []);
|
||||
|
||||
const openMovementGallery = useCallback((movementId: number, data: MovementGalleryDetail) => {
|
||||
const session: GallerySession = { kind: 'movement', movementId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
}, []);
|
||||
|
||||
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
|
||||
const session: GallerySession = { kind: 'tour', tourId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'tour-gallery', tourId, data });
|
||||
}, []);
|
||||
|
||||
const handleSelectPublishedTour = useCallback(
|
||||
async (tourId: number) => {
|
||||
setToursPopupOpen(false);
|
||||
setGalleryEntryLoading(t('openingTourGallery'));
|
||||
try {
|
||||
const data = await api.getTour(tourId);
|
||||
if (!data.paintings.length) {
|
||||
setError(t('tourEmpty'));
|
||||
return;
|
||||
}
|
||||
openTourGallery(tourId, data);
|
||||
} catch {
|
||||
setError(t('tourLoadFailed'));
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
},
|
||||
[openTourGallery, t]
|
||||
);
|
||||
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
setGalleryEntryLoading('Opening artist gallery…');
|
||||
try {
|
||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
||||
const data = await api.getArtist(artistId);
|
||||
setGallerySession({ kind: 'artist', artistId, data });
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
openArtistGallery(artistId, data);
|
||||
} catch {
|
||||
setError('Failed to load artist gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMovementClick = async (movementId: number) => {
|
||||
setGalleryEntryLoading('Opening movement gallery…');
|
||||
try {
|
||||
const data = await api.getMovementGallery(movementId);
|
||||
setGallerySession({ kind: 'movement', movementId, data });
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
openMovementGallery(movementId, data);
|
||||
} catch {
|
||||
setError('Failed to load movement gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -399,25 +614,38 @@ export default function HomePage() {
|
||||
const currentView = viewRef.current;
|
||||
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
|
||||
|
||||
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
const sorted =
|
||||
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
|
||||
? detailArtistPaintings
|
||||
: sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
const nextId = catalogNavigateTarget(sorted, paintingId);
|
||||
const inMovementCatalog =
|
||||
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
|
||||
const inTourCatalog =
|
||||
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
|
||||
|
||||
await api.deletePainting(paintingId);
|
||||
|
||||
const freshArtist = await api.getArtist(artistId);
|
||||
let freshMovement: MovementGalleryDetail | undefined;
|
||||
let freshTour: TourGalleryDetail | undefined;
|
||||
if (gallerySession?.kind === 'movement') {
|
||||
freshMovement = await api.getMovementGallery(gallerySession.movementId);
|
||||
} else if (currentView.returnTo.type === 'movement-gallery') {
|
||||
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
|
||||
}
|
||||
if (gallerySession?.kind === 'tour') {
|
||||
freshTour = await api.getTour(gallerySession.tourId);
|
||||
} else if (currentView.returnTo.type === 'tour-gallery') {
|
||||
freshTour = await api.getTour(currentView.returnTo.tourId);
|
||||
}
|
||||
|
||||
const freshCatalog =
|
||||
inMovementCatalog && freshMovement
|
||||
? sortArtistPaintingsChronological(freshMovement.paintings)
|
||||
: sortArtistPaintingsChronological(freshArtist.paintings);
|
||||
inTourCatalog && freshTour
|
||||
? freshTour.paintings
|
||||
: inMovementCatalog && freshMovement
|
||||
? sortArtistPaintingsChronological(freshMovement.paintings)
|
||||
: sortArtistPaintingsChronological(freshArtist.paintings);
|
||||
|
||||
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
|
||||
const navigateId =
|
||||
@@ -438,6 +666,9 @@ export default function HomePage() {
|
||||
if (session.kind === 'movement' && freshMovement) {
|
||||
return { ...session, data: freshMovement };
|
||||
}
|
||||
if (session.kind === 'tour' && freshTour) {
|
||||
return { ...session, data: freshTour };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
@@ -452,7 +683,8 @@ export default function HomePage() {
|
||||
const patchedReturnTo = patchReturnToAfterRemove(
|
||||
currentView.returnTo,
|
||||
freshArtist,
|
||||
freshMovement
|
||||
freshMovement,
|
||||
freshTour
|
||||
);
|
||||
detailReturnToRef.current = patchedReturnTo;
|
||||
|
||||
@@ -463,6 +695,9 @@ export default function HomePage() {
|
||||
if (current.type === 'movement-gallery' && freshMovement) {
|
||||
return { ...current, data: freshMovement };
|
||||
}
|
||||
if (current.type === 'tour-gallery' && freshTour) {
|
||||
return { ...current, data: freshTour };
|
||||
}
|
||||
if (current.type !== 'painting' || current.paintingId !== paintingId) {
|
||||
return current;
|
||||
}
|
||||
@@ -494,12 +729,20 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const artistId = view.data.painting.artist_id;
|
||||
if (gallerySession?.kind === 'tour') {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = detailReturnToRef.current;
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
setDetailArtistPaintings(returnTo.data.paintings);
|
||||
return;
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
|
||||
return;
|
||||
@@ -519,44 +762,89 @@ export default function HomePage() {
|
||||
};
|
||||
}, [view, gallerySession]);
|
||||
|
||||
const sortedDetailArtistPaintings = useMemo(
|
||||
() => sortArtistPaintingsChronological(detailArtistPaintings),
|
||||
[detailArtistPaintings]
|
||||
);
|
||||
const sortedDetailArtistPaintings = useMemo(() => {
|
||||
const fromTourSession = gallerySession?.kind === 'tour';
|
||||
const fromTourReturn =
|
||||
view.type === 'painting' && view.returnTo.type === 'tour-gallery';
|
||||
if (fromTourSession || fromTourReturn) {
|
||||
return detailArtistPaintings;
|
||||
}
|
||||
return sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
}, [detailArtistPaintings, gallerySession, view]);
|
||||
|
||||
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
|
||||
const tourOverlay =
|
||||
view.type === 'painting' && gallerySession?.kind === 'tour'
|
||||
? {
|
||||
title: gallerySession.data.tour.title,
|
||||
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
|
||||
}
|
||||
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
|
||||
? {
|
||||
title: view.returnTo.data.tour.title,
|
||||
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
|
||||
}
|
||||
: null;
|
||||
|
||||
const galleryActive =
|
||||
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
|
||||
|
||||
const displayGallery = useMemo((): GallerySession | null => {
|
||||
if (view.type === 'gallery') {
|
||||
return { kind: 'artist', artistId: view.artistId, data: view.data };
|
||||
}
|
||||
if (view.type === 'movement-gallery') {
|
||||
return { kind: 'movement', movementId: view.movementId, data: view.data };
|
||||
}
|
||||
if (view.type === 'tour-gallery') {
|
||||
return { kind: 'tour', tourId: view.tourId, data: view.data };
|
||||
}
|
||||
return gallerySession;
|
||||
}, [view, gallerySession]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{gallerySession && (
|
||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||
{gallerySession.kind === 'artist' ? (
|
||||
{displayGallery && (
|
||||
<div
|
||||
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
|
||||
aria-hidden={!galleryActive}
|
||||
>
|
||||
{displayGallery.kind === 'artist' ? (
|
||||
<VirtualGallery
|
||||
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}`}
|
||||
mode="artist"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
onBioClick={() =>
|
||||
handleBioClick(gallerySession.data, {
|
||||
handleBioClick(displayGallery.data, {
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
artistId: displayGallery.artistId,
|
||||
data: displayGallery.data,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : displayGallery.kind === 'movement' ? (
|
||||
<VirtualGallery
|
||||
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
|
||||
mode="movement"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
/>
|
||||
) : (
|
||||
<VirtualGallery
|
||||
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
|
||||
mode="tour"
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={goToTimelineHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -568,28 +856,39 @@ export default function HomePage() {
|
||||
key={view.paintingId}
|
||||
data={view.data}
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
|
||||
tourTitle={tourOverlay?.title ?? null}
|
||||
tourText={tourOverlay ? tourOverlay.text : null}
|
||||
onBack={() => {
|
||||
if (view.returnTo.type === 'timeline') {
|
||||
goToTimelineHome();
|
||||
return;
|
||||
}
|
||||
const returnTo = view.returnTo;
|
||||
if (
|
||||
returnTo.type === 'gallery' &&
|
||||
gallerySession?.kind === 'artist' &&
|
||||
gallerySession.artistId === returnTo.artistId
|
||||
) {
|
||||
setView({
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openArtistGallery(gallerySession.artistId, gallerySession.data);
|
||||
} else if (
|
||||
returnTo.type === 'movement-gallery' &&
|
||||
gallerySession?.kind === 'movement' &&
|
||||
gallerySession.movementId === returnTo.movementId
|
||||
) {
|
||||
setView({
|
||||
type: 'movement-gallery',
|
||||
movementId: gallerySession.movementId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openMovementGallery(gallerySession.movementId, gallerySession.data);
|
||||
} else if (
|
||||
returnTo.type === 'tour-gallery' &&
|
||||
gallerySession?.kind === 'tour' &&
|
||||
gallerySession.tourId === returnTo.tourId
|
||||
) {
|
||||
openTourGallery(gallerySession.tourId, gallerySession.data);
|
||||
} else if (returnTo.type === 'gallery') {
|
||||
openArtistGallery(returnTo.artistId, returnTo.data);
|
||||
} else if (returnTo.type === 'movement-gallery') {
|
||||
openMovementGallery(returnTo.movementId, returnTo.data);
|
||||
} else if (returnTo.type === 'tour-gallery') {
|
||||
openTourGallery(returnTo.tourId, returnTo.data);
|
||||
} else {
|
||||
setView(returnTo);
|
||||
}
|
||||
@@ -601,8 +900,8 @@ export default function HomePage() {
|
||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
@@ -614,12 +913,12 @@ export default function HomePage() {
|
||||
<div className="home-overlay">
|
||||
<ArtistBio
|
||||
artist={view.data.artist}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||
onBack={() => setView(view.returnTo)}
|
||||
onEnterGallery={() =>
|
||||
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
||||
openArtistGallery(view.artistId, view.data)
|
||||
}
|
||||
onArtistPortraitFixed={handleArtistPortraitFixed}
|
||||
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
||||
@@ -627,50 +926,209 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
<CheckupPage
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
{view.type === 'influences' && (
|
||||
isCurator ? (
|
||||
<InfluencesPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('influences')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'tours' && (
|
||||
isCurator ? (
|
||||
<ToursPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
onBack={goToTimelineHome}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>Curator access required</h2>
|
||||
<p>The painting checkup table is available to logged-in curators only.</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||
Curator login
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
Back to gallery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<CuratorLoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => {
|
||||
setLoginOpen(false);
|
||||
setLoginRedirect(null);
|
||||
}}
|
||||
onLogin={handleCuratorLogin}
|
||||
/>
|
||||
|
||||
<ToursPopup
|
||||
open={toursPopupOpen}
|
||||
onClose={() => setToursPopupOpen(false)}
|
||||
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
|
||||
/>
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-dev-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
{isCurator ? (
|
||||
<>
|
||||
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||
{username}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openInfluences}
|
||||
title="Manage influence links"
|
||||
>
|
||||
{t('influences')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openToursEditor}
|
||||
title="Create and edit guided tours"
|
||||
>
|
||||
{t('toursEditor')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openTranslations}
|
||||
title="Review and publish Russian translations"
|
||||
>
|
||||
{t('translations')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openCheckup}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
{t('checkup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="curator-logout-btn"
|
||||
onClick={handleCuratorLogout}
|
||||
title="Sign out curator session"
|
||||
>
|
||||
{t('curatorLogout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="curator-login-btn"
|
||||
onClick={() => openCuratorLogin()}
|
||||
title="Sign in as curator to use debug tools"
|
||||
>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={() => setView({ type: 'checkup' })}
|
||||
title="Open painting image checkup table"
|
||||
onClick={() => setToursPopupOpen(true)}
|
||||
title={t('tours')}
|
||||
>
|
||||
Checkup
|
||||
{t('tours')}
|
||||
</button>
|
||||
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="site-subtitle">{t('subtitle')}</p>
|
||||
<CatalogSearchBar
|
||||
onSelectArtist={handleArtistClick}
|
||||
onSelectMovement={handleMovementClick}
|
||||
onSelectPainting={handlePaintingClick}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
{galleryEntryLoading && (
|
||||
<GalleryLoadingMarker overlay message={galleryEntryLoading} />
|
||||
)}
|
||||
{portraitsLoading && !loading && !galleryEntryLoading && (
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
@@ -683,9 +1141,7 @@ export default function HomePage() {
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
{!loading && (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
@@ -701,6 +1157,7 @@ export default function HomePage() {
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
onPortraitsLoadingChange={setPortraitsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
.influences-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.influences-loading-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(10, 10, 20, 0.72);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.influences-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.influences-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
flex: 1;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-back {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-back:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-tabs button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-tabs button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs button.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
color: #e8d5b5;
|
||||
border-color: #e8a040;
|
||||
}
|
||||
|
||||
.influences-error {
|
||||
background: rgba(139, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||
color: #ffaaaa;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.influences-success {
|
||||
background: rgba(22, 101, 52, 0.35);
|
||||
border: 1px solid rgba(134, 239, 172, 0.35);
|
||||
color: #bbf7d0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.influences-dup-warn {
|
||||
background: rgba(120, 53, 15, 0.45);
|
||||
border: 1px solid rgba(251, 191, 36, 0.45);
|
||||
color: #fde68a;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.influences-dup-warn p {
|
||||
margin: 0;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.influences-force {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #e8d5b5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-force input {
|
||||
accent-color: #e8a040;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.influences-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.influences-toolbar input[type='search'],
|
||||
.influences-add input,
|
||||
.influences-add textarea,
|
||||
.influences-add select,
|
||||
.wizard-block select,
|
||||
.wizard-block input {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
min-width: 12rem;
|
||||
font: inherit;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar input::placeholder,
|
||||
.influences-add input::placeholder,
|
||||
.wizard-block input::placeholder {
|
||||
color: rgba(201, 169, 110, 0.5);
|
||||
}
|
||||
|
||||
.influences-toolbar button,
|
||||
.wizard-actions button,
|
||||
.influences-add button,
|
||||
.influences-hits button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.influences-toolbar button:hover,
|
||||
.wizard-actions button:hover,
|
||||
.influences-add button:hover,
|
||||
.influences-hits button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-meta {
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-add {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 15, 26, 0.55);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-add label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-hits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.influences-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th,
|
||||
.influences-table td {
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.15);
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th {
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 18rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e8a040;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkish:hover {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ffaaaa !important;
|
||||
border-color: rgba(255, 170, 170, 0.4) !important;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wizard-steps li {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-steps li.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block p,
|
||||
.wizard-block label,
|
||||
.wizard-block summary {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.warnings-list {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
font-size: 0.85rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.mapping-table select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.influences-graph h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.15rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
height: auto;
|
||||
background: rgba(15, 15, 26, 0.65);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.influences-svg .edge-in {
|
||||
stroke: rgba(201, 169, 110, 0.55);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .edge-out {
|
||||
stroke: #e8a040;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .node circle {
|
||||
fill: rgba(201, 169, 110, 0.35);
|
||||
stroke: #c9a96e;
|
||||
stroke-width: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-svg .node-artist circle {
|
||||
fill: rgba(96, 165, 250, 0.35);
|
||||
stroke: #93c5fd;
|
||||
}
|
||||
|
||||
.influences-svg .node-movement circle {
|
||||
fill: rgba(74, 222, 128, 0.3);
|
||||
stroke: #86efac;
|
||||
}
|
||||
|
||||
.influences-svg .node-painting circle {
|
||||
fill: rgba(251, 146, 60, 0.3);
|
||||
stroke: #fdba74;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus circle {
|
||||
fill: #e8a040;
|
||||
stroke: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg .node text {
|
||||
font-size: 10px;
|
||||
fill: #e8d5b5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus text {
|
||||
font-weight: 650;
|
||||
fill: #e8d5b5;
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type InfluenceEdgeItem,
|
||||
type InfluenceGraph,
|
||||
type InfluenceImportParseResult,
|
||||
type InfluenceImportPreview,
|
||||
type InfluenceImportProposal,
|
||||
} from '../api/client';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import './InfluencesPage.css';
|
||||
|
||||
type Tab = 'list' | 'import' | 'graph';
|
||||
type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'ignore',
|
||||
'subject_artist',
|
||||
'subject_painting',
|
||||
'influenced_by',
|
||||
'influenced',
|
||||
'notes',
|
||||
'reference',
|
||||
'source_url',
|
||||
] as const;
|
||||
|
||||
export default function InfluencesPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('influences');
|
||||
const [tab, setTab] = useState<Tab>('list');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// List
|
||||
const [items, setItems] = useState<InfluenceEdgeItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterArtistId, setFilterArtistId] = useState<number | null>(null);
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addPaintingQuery, setAddPaintingQuery] = useState('');
|
||||
const [addSourceQuery, setAddSourceQuery] = useState('');
|
||||
const [addSourceType, setAddSourceType] = useState<'artist' | 'painting' | 'movement'>('artist');
|
||||
const [addPaintingId, setAddPaintingId] = useState<number | null>(null);
|
||||
const [addSourceId, setAddSourceId] = useState<number | null>(null);
|
||||
const [addNotes, setAddNotes] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
const [sourceHits, setSourceHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
|
||||
// Graph
|
||||
const [graphArtistQuery, setGraphArtistQuery] = useState('');
|
||||
const [graphArtistId, setGraphArtistId] = useState<number | null>(null);
|
||||
const [graphArtistHits, setGraphArtistHits] = useState<Array<{ id: number; label: string }>>([]);
|
||||
const [graph, setGraph] = useState<InfluenceGraph | null>(null);
|
||||
|
||||
// Import wizard
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>('upload');
|
||||
const [parseResult, setParseResult] = useState<InfluenceImportParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [selectedSheet, setSelectedSheet] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<InfluenceImportPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyMessage, setBusyMessage] = useState('');
|
||||
const [commitResult, setCommitResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
||||
const [forceImport, setForceImport] = useState(false);
|
||||
|
||||
const startBusy = (message: string) => {
|
||||
setBusyMessage(message);
|
||||
setBusy(true);
|
||||
};
|
||||
|
||||
const stopBusy = () => {
|
||||
setBusy(false);
|
||||
setBusyMessage('');
|
||||
};
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listInfluences({
|
||||
q: q || undefined,
|
||||
artistId: filterArtistId || undefined,
|
||||
limit: 200,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, filterArtistId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'list') void loadList();
|
||||
}, [tab, loadList]);
|
||||
|
||||
const runSearch = async (query: string, types: string) => {
|
||||
if (query.trim().length < 2) return [] as Array<{ type: string; id: number; label: string }>;
|
||||
const data = await api.search(query.trim(), { types, limit: 12 });
|
||||
return data.results.map((r) => {
|
||||
if (r.type === 'painting') {
|
||||
return { type: r.type, id: r.id, label: `${r.artist_name} — ${r.title}` };
|
||||
}
|
||||
return { type: r.type, id: r.id, label: r.name };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSearchHits(await runSearch(addPaintingQuery, 'painting'));
|
||||
} catch {
|
||||
setSearchHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addPaintingQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSourceHits(await runSearch(addSourceQuery, addSourceType));
|
||||
} catch {
|
||||
setSourceHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addSourceQuery, addSourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const hits = await runSearch(graphArtistQuery, 'artist');
|
||||
setGraphArtistHits(hits.filter((h) => h.type === 'artist').map((h) => ({ id: h.id, label: h.label })));
|
||||
} catch {
|
||||
setGraphArtistHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [graphArtistQuery]);
|
||||
|
||||
const loadGraph = async (artistId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getInfluenceGraph({ artistId });
|
||||
setGraph(data);
|
||||
setGraphArtistId(artistId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
try {
|
||||
await api.deleteInfluence(id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!addPaintingId || !addSourceId) {
|
||||
setError(t('addRequiresIds'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingSave'));
|
||||
setError(null);
|
||||
try {
|
||||
const payload: Parameters<typeof api.createInfluence>[0] = {
|
||||
paintingId: addPaintingId,
|
||||
sourceType: addSourceType,
|
||||
notes: addNotes || undefined,
|
||||
source: 'curator-ui',
|
||||
};
|
||||
if (addSourceType === 'artist') payload.sourceArtistId = addSourceId;
|
||||
if (addSourceType === 'painting') payload.sourcePaintingId = addSourceId;
|
||||
if (addSourceType === 'movement') payload.sourceMovementId = addSourceId;
|
||||
await api.createInfluence(payload);
|
||||
setShowAdd(false);
|
||||
setAddPaintingId(null);
|
||||
setAddSourceId(null);
|
||||
setAddNotes('');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChosen = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setPendingFile(file);
|
||||
startBusy(t('loadingParse'));
|
||||
setError(null);
|
||||
setCommitResult(null);
|
||||
setForceImport(false);
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(file);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(parsed.sheet);
|
||||
setWizardStep('mapping');
|
||||
setPreview(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const reparseSheet = async (sheet: string) => {
|
||||
if (!pendingFile) return;
|
||||
startBusy(t('loadingParse'));
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(pendingFile, sheet);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(sheet);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreset = (presetId: string) => {
|
||||
if (!parseResult) return;
|
||||
const preset = parseResult.presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const col of parseResult.columns) next[col] = 'ignore';
|
||||
for (const [col, role] of Object.entries(preset.mapping)) {
|
||||
if (parseResult.columns.includes(col)) next[col] = role;
|
||||
}
|
||||
// Fill gaps with auto suggestions
|
||||
for (const [col, role] of Object.entries(parseResult.suggestedMapping)) {
|
||||
if (next[col] === 'ignore' && role !== 'ignore') next[col] = role;
|
||||
}
|
||||
setMapping(next);
|
||||
};
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!parseResult?.rows) {
|
||||
setError(t('rowsMissing'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingPreview'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.previewInfluenceImport({
|
||||
rows: parseResult.rows,
|
||||
mapping,
|
||||
sourceLabel: parseResult.filename,
|
||||
contentHash: parseResult.contentHash,
|
||||
payloadHash: parseResult.payloadHash,
|
||||
});
|
||||
setPreview(result);
|
||||
setWizardStep('preview');
|
||||
if (result.alreadyImported) setForceImport(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('previewFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const runCommit = async () => {
|
||||
if (!preview) return;
|
||||
const already = preview.alreadyImported || parseResult?.alreadyImported;
|
||||
if (already && !forceImport) {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingCommit'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.commitInfluenceImport({
|
||||
proposals: preview.proposals,
|
||||
fileName: parseResult?.filename,
|
||||
contentHash: preview.contentHash || parseResult?.contentHash,
|
||||
payloadHash: preview.payloadHash || parseResult?.payloadHash,
|
||||
force: forceImport,
|
||||
});
|
||||
setCommitResult(result);
|
||||
setWizardStep('done');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
if (e.code === 'ALREADY_IMPORTED') {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('commitFailed'));
|
||||
}
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const priorImport = preview?.priorImport || parseResult?.priorImport || null;
|
||||
const alreadyImported = Boolean(preview?.alreadyImported || parseResult?.alreadyImported);
|
||||
|
||||
const createProposals = useMemo(
|
||||
() => (preview?.proposals || []).filter((p) => p.action === 'create').slice(0, 200),
|
||||
[preview],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="influences-page">
|
||||
{busy && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="influences-loading-overlay"
|
||||
message={busyMessage || t('loading')}
|
||||
/>
|
||||
)}
|
||||
<header className="influences-header">
|
||||
<button type="button" className="influences-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
<nav className="influences-tabs">
|
||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||
{t('tabList')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'import' ? 'active' : ''} onClick={() => setTab('import')}>
|
||||
{t('tabImport')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'graph' ? 'active' : ''} onClick={() => setTab('graph')}>
|
||||
{t('tabGraph')}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{error && <div className="influences-error">{error}</div>}
|
||||
|
||||
{tab === 'list' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
<button type="button" onClick={() => void loadList()} disabled={loading}>
|
||||
{loading ? t('loading') : t('refresh')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowAdd((v) => !v)}>
|
||||
{showAdd ? t('cancelAdd') : t('addEdge')}
|
||||
</button>
|
||||
<span className="influences-meta">
|
||||
{t('total', { count: total })}
|
||||
{filterArtistId ? ` · ${t('filtered')}` : ''}
|
||||
</span>
|
||||
{filterArtistId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilterArtistId(null);
|
||||
}}
|
||||
>
|
||||
{t('clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="influences-add">
|
||||
<label>
|
||||
{t('subjectPainting')}
|
||||
<input
|
||||
value={addPaintingQuery}
|
||||
onChange={(e) => {
|
||||
setAddPaintingQuery(e.target.value);
|
||||
setAddPaintingId(null);
|
||||
}}
|
||||
placeholder={t('searchPainting')}
|
||||
/>
|
||||
</label>
|
||||
{searchHits.length > 0 && !addPaintingId && (
|
||||
<ul className="influences-hits">
|
||||
{searchHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddPaintingId(h.id);
|
||||
setAddPaintingQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('sourceType')}
|
||||
<select
|
||||
value={addSourceType}
|
||||
onChange={(e) => {
|
||||
setAddSourceType(e.target.value as 'artist' | 'painting' | 'movement');
|
||||
setAddSourceId(null);
|
||||
setAddSourceQuery('');
|
||||
}}
|
||||
>
|
||||
<option value="artist">{t('typeArtist')}</option>
|
||||
<option value="painting">{t('typePainting')}</option>
|
||||
<option value="movement">{t('typeMovement')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('sourceEntity')}
|
||||
<input
|
||||
value={addSourceQuery}
|
||||
onChange={(e) => {
|
||||
setAddSourceQuery(e.target.value);
|
||||
setAddSourceId(null);
|
||||
}}
|
||||
placeholder={t('searchSource')}
|
||||
/>
|
||||
</label>
|
||||
{sourceHits.length > 0 && !addSourceId && (
|
||||
<ul className="influences-hits">
|
||||
{sourceHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddSourceId(h.id);
|
||||
setAddSourceQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('notes')}
|
||||
<textarea value={addNotes} onChange={(e) => setAddNotes(e.target.value)} rows={2} />
|
||||
</label>
|
||||
<button type="button" disabled={busy} onClick={() => void handleCreate()}>
|
||||
{t('saveEdge')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colNotes')}</th>
|
||||
<th>{t('colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => {
|
||||
setFilterArtistId(item.artistId);
|
||||
setTab('graph');
|
||||
void loadGraph(item.artistId);
|
||||
}}
|
||||
>
|
||||
{item.artistName}
|
||||
</button>
|
||||
<div className="muted">{item.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{item.sourceLabel || '—'}</td>
|
||||
<td>{item.sourceType}</td>
|
||||
<td className="notes-cell">{item.notes || '—'}</td>
|
||||
<td>
|
||||
<button type="button" className="danger" onClick={() => void handleDelete(item.id)}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>{t('noEdges')}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<section className="influences-panel">
|
||||
<ol className="wizard-steps">
|
||||
<li className={wizardStep === 'upload' ? 'active' : ''}>{t('stepUpload')}</li>
|
||||
<li className={wizardStep === 'mapping' ? 'active' : ''}>{t('stepMapping')}</li>
|
||||
<li className={wizardStep === 'preview' ? 'active' : ''}>{t('stepPreview')}</li>
|
||||
<li className={wizardStep === 'done' ? 'active' : ''}>{t('stepDone')}</li>
|
||||
</ol>
|
||||
|
||||
{(wizardStep === 'upload' || wizardStep === 'done') && (
|
||||
<div className="wizard-block">
|
||||
<p>{t('uploadHelp')}</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.json,.xlsx,.xls,application/json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(e) => void onFileChosen(e.target.files?.[0] || null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{commitResult && (
|
||||
<p className="influences-success">
|
||||
{t('commitSummary', { inserted: commitResult.inserted, skipped: commitResult.skipped })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'mapping' && parseResult && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('fileInfo', {
|
||||
name: parseResult.filename,
|
||||
rows: parseResult.rowCount,
|
||||
format: parseResult.format,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult.filename,
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{parseResult.sheets && parseResult.sheets.length > 1 && (
|
||||
<label>
|
||||
{t('sheet')}
|
||||
<select
|
||||
value={selectedSheet || ''}
|
||||
onChange={(e) => void reparseSheet(e.target.value)}
|
||||
>
|
||||
{parseResult.sheets.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t('preset')}
|
||||
<select
|
||||
defaultValue={parseResult.suggestedPreset}
|
||||
onChange={(e) => applyPreset(e.target.value)}
|
||||
>
|
||||
{parseResult.presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<table className="influences-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('column')}</th>
|
||||
<th>{t('role')}</th>
|
||||
<th>{t('sample')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parseResult.columns.map((col) => (
|
||||
<tr key={col}>
|
||||
<td>{col}</td>
|
||||
<td>
|
||||
<select
|
||||
value={mapping[col] || 'ignore'}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{parseResult.sampleRows[0]?.[col]?.slice(0, 80) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('upload')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button type="button" disabled={busy || !parseResult.rows} onClick={() => void runPreview()}>
|
||||
{t('runPreview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'preview' && preview && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('previewCounts', {
|
||||
create: preview.counts.willCreate,
|
||||
skip: preview.counts.willSkip,
|
||||
errors: preview.counts.errors,
|
||||
proposals: preview.counts.proposals,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult?.filename || '—',
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
<label className="influences-force">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceImport}
|
||||
onChange={(e) => setForceImport(e.target.checked)}
|
||||
/>
|
||||
{t('forceImport')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{preview.warnings.length > 0 && (
|
||||
<details open={preview.warnings.length < 30}>
|
||||
<summary>
|
||||
{t('warnings', { count: preview.warnings.length })}
|
||||
</summary>
|
||||
<ul className="warnings-list">
|
||||
{preview.warnings.slice(0, 80).map((w, i) => (
|
||||
<li key={`${w.rowIndex}-${i}`}>
|
||||
#{w.rowIndex + 1}: {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colDirection')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{createProposals.map((p: InfluenceImportProposal) => (
|
||||
<tr key={p.edgeKey}>
|
||||
<td>{p.action}</td>
|
||||
<td>
|
||||
{p.artistName}
|
||||
<div className="muted">{p.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{p.sourceLabel}</td>
|
||||
<td>{p.sourceType}</td>
|
||||
<td>{p.direction}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('mapping')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
busy
|
||||
|| preview.counts.willCreate === 0
|
||||
|| (alreadyImported && !forceImport)
|
||||
}
|
||||
onClick={() => void runCommit()}
|
||||
>
|
||||
{t('commitImport', { count: preview.counts.willCreate })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'graph' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={graphArtistQuery}
|
||||
onChange={(e) => setGraphArtistQuery(e.target.value)}
|
||||
placeholder={t('searchArtist')}
|
||||
/>
|
||||
</div>
|
||||
{graphArtistHits.length > 0 && (
|
||||
<ul className="influences-hits">
|
||||
{graphArtistHits.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" onClick={() => void loadGraph(h.id)}>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{graph && (
|
||||
<>
|
||||
{graphArtistId != null && (
|
||||
<p className="muted">Artist id: {graphArtistId}</p>
|
||||
)}
|
||||
<InfluenceGraphSvg graph={graph} onSelectArtist={(id) => {
|
||||
setFilterArtistId(id);
|
||||
setTab('list');
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
{!graph && <p className="muted">{t('graphEmpty')}</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfluenceGraphSvg({
|
||||
graph,
|
||||
onSelectArtist,
|
||||
}: {
|
||||
graph: InfluenceGraph;
|
||||
onSelectArtist: (artistId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('influences');
|
||||
const width = 720;
|
||||
const height = 420;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const focus = graph.nodes.find((n) => n.focus) || graph.nodes[0];
|
||||
const others = graph.nodes.filter((n) => n.id !== focus?.id);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (focus) positions.set(focus.id, { x: cx, y: cy });
|
||||
others.forEach((n, i) => {
|
||||
const angle = (Math.PI * 2 * i) / Math.max(others.length, 1) - Math.PI / 2;
|
||||
const r = 140 + (i % 3) * 28;
|
||||
positions.set(n.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="influences-graph">
|
||||
<h2>{graph.focus.label}</h2>
|
||||
<p className="muted">
|
||||
{t('graphStats', { nodes: graph.nodes.length, edges: graph.edges.length })}
|
||||
</p>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="influences-svg" role="img">
|
||||
{graph.edges.map((e) => {
|
||||
const a = positions.get(e.from);
|
||||
const b = positions.get(e.to);
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${e.id}-${e.from}-${e.to}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className={e.direction === 'influenced_by' ? 'edge-in' : 'edge-out'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => {
|
||||
const p = positions.get(n.id);
|
||||
if (!p) return null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${p.x},${p.y})`}
|
||||
className={`node node-${n.type}${n.focus ? ' focus' : ''}`}
|
||||
onClick={() => {
|
||||
if (n.artistId) onSelectArtist(n.artistId);
|
||||
}}
|
||||
>
|
||||
<circle r={n.focus ? 22 : 14} />
|
||||
<title>{n.label}</title>
|
||||
<text y={n.focus ? 36 : 28} textAnchor="middle">
|
||||
{n.label.length > 28 ? `${n.label.slice(0, 26)}…` : n.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.tours-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.tours-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-back {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tours-error {
|
||||
background: rgba(139, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||
color: #ffaaaa;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tours-list-panel,
|
||||
.tours-editor {
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(15, 15, 26, 0.45);
|
||||
}
|
||||
|
||||
.tours-create {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tours-create input,
|
||||
.tours-meta input,
|
||||
.tours-meta textarea,
|
||||
.tours-meta select,
|
||||
.tours-stops-toolbar input,
|
||||
.tours-stops textarea {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #e8d5b5;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tours-create button,
|
||||
.tours-meta-actions button,
|
||||
.tours-stops-toolbar button,
|
||||
.tours-stop-move button,
|
||||
.tours-hits button,
|
||||
.tours-list button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tours-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tours-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.tours-list button.active {
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
background: rgba(232, 160, 64, 0.15);
|
||||
}
|
||||
|
||||
.tours-meta {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-meta label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-meta-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tours-stops-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tours-hits {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tours-stops {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tours-stop-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tours-stop-move {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ffaaaa !important;
|
||||
border-color: rgba(255, 170, 170, 0.4) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tours-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type TourSummary } from '../api/client';
|
||||
import type { Painting } from '../types';
|
||||
import './ToursPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface StopDraft {
|
||||
paintingId: number;
|
||||
title: string;
|
||||
artistName: string;
|
||||
year: number | null;
|
||||
thumbnailPath: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export default function ToursPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('tours');
|
||||
const [tours, setTours] = useState<TourSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [status, setStatus] = useState<'draft' | 'published'>('draft');
|
||||
const [stops, setStops] = useState<StopDraft[]>([]);
|
||||
const [searchQ, setSearchQ] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<
|
||||
Array<{ id: number; title: string; artistName: string; year: number | null; thumbnailPath: string | null }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listAdminTours();
|
||||
setTours(data.tours);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const openTour = async (id: number) => {
|
||||
setSelectedId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getTour(id);
|
||||
setTitle(data.tour.title);
|
||||
setDescription(data.tour.description || '');
|
||||
setStatus(data.tour.status);
|
||||
setStops(
|
||||
data.paintings.map((p: Painting) => ({
|
||||
paintingId: p.id,
|
||||
title: p.title,
|
||||
artistName: p.artist_name || '',
|
||||
year: p.year ?? null,
|
||||
thumbnailPath: p.thumbnail_path || null,
|
||||
body: data.stopBodies[p.id] || '',
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
if (searchQ.trim().length < 2) {
|
||||
setSearchHits([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.search(searchQ.trim(), { types: 'painting', limit: 12 });
|
||||
setSearchHits(
|
||||
data.results
|
||||
.filter((r) => r.type === 'painting')
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
artistName: r.artist_name,
|
||||
year: r.year,
|
||||
thumbnailPath: r.thumbnail_path,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setSearchHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQ]);
|
||||
|
||||
const createTour = async () => {
|
||||
const name = newTitle.trim();
|
||||
if (!name) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { tour } = await api.createTour({ title: name, status: 'draft' });
|
||||
setNewTitle('');
|
||||
await loadList();
|
||||
await openTour(tour.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateTour(selectedId, {
|
||||
title: title.trim(),
|
||||
description,
|
||||
status,
|
||||
coverPaintingId: stops[0]?.paintingId ?? null,
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveStops = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveTourStops(
|
||||
selectedId,
|
||||
stops.map((s) => ({ paintingId: s.paintingId, body: s.body })),
|
||||
);
|
||||
await api.updateTour(selectedId, {
|
||||
coverPaintingId: stops[0]?.paintingId ?? null,
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTour = async () => {
|
||||
if (!selectedId) return;
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.deleteTour(selectedId);
|
||||
setSelectedId(null);
|
||||
setStops([]);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addStop = (hit: {
|
||||
id: number;
|
||||
title: string;
|
||||
artistName: string;
|
||||
year: number | null;
|
||||
thumbnailPath: string | null;
|
||||
}) => {
|
||||
if (stops.some((s) => s.paintingId === hit.id)) return;
|
||||
setStops((prev) => [
|
||||
...prev,
|
||||
{
|
||||
paintingId: hit.id,
|
||||
title: hit.title,
|
||||
artistName: hit.artistName,
|
||||
year: hit.year,
|
||||
thumbnailPath: hit.thumbnailPath,
|
||||
body: '',
|
||||
},
|
||||
]);
|
||||
setSearchQ('');
|
||||
setSearchHits([]);
|
||||
};
|
||||
|
||||
const moveStop = (index: number, dir: -1 | 1) => {
|
||||
const next = index + dir;
|
||||
if (next < 0 || next >= stops.length) return;
|
||||
setStops((prev) => {
|
||||
const copy = [...prev];
|
||||
const tmp = copy[index];
|
||||
copy[index] = copy[next];
|
||||
copy[next] = tmp;
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tours-page">
|
||||
<header className="tours-header">
|
||||
<button type="button" className="tours-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{error && <div className="tours-error">{error}</div>}
|
||||
|
||||
<div className="tours-layout">
|
||||
<aside className="tours-list-panel">
|
||||
<div className="tours-create">
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t('newTourPlaceholder')}
|
||||
/>
|
||||
<button type="button" disabled={saving || !newTitle.trim()} onClick={() => void createTour()}>
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">{t('loading')}</p>
|
||||
) : (
|
||||
<ul className="tours-list">
|
||||
{tours.map((tour) => (
|
||||
<li key={tour.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedId === tour.id ? 'active' : ''}
|
||||
onClick={() => void openTour(tour.id)}
|
||||
>
|
||||
<strong>{tour.title}</strong>
|
||||
<span className="muted">
|
||||
{tour.status} · {t('stopCount', { count: tour.stopCount })}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{tours.length === 0 && <li className="muted">{t('noTours')}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="tours-editor">
|
||||
{!selectedId ? (
|
||||
<p className="muted">{t('selectTour')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="tours-meta">
|
||||
<label>
|
||||
{t('tourTitle')}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('tourDescription')}
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</label>
|
||||
<label>
|
||||
{t('status')}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
|
||||
>
|
||||
<option value="draft">{t('draft')}</option>
|
||||
<option value="published">{t('published')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="tours-meta-actions">
|
||||
<button type="button" disabled={saving} onClick={() => void saveMeta()}>
|
||||
{t('saveMeta')}
|
||||
</button>
|
||||
<button type="button" className="danger" disabled={saving} onClick={() => void deleteTour()}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tours-stops-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder={t('searchPainting')}
|
||||
/>
|
||||
<button type="button" disabled={saving} onClick={() => void saveStops()}>
|
||||
{t('saveStops')}
|
||||
</button>
|
||||
</div>
|
||||
{searchHits.length > 0 && (
|
||||
<ul className="tours-hits">
|
||||
{searchHits.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" onClick={() => addStop(h)}>
|
||||
{h.artistName} — {h.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ol className="tours-stops">
|
||||
{stops.map((stop, index) => (
|
||||
<li key={stop.paintingId}>
|
||||
<div className="tours-stop-head">
|
||||
<span>
|
||||
{index + 1}. {stop.artistName} — {stop.title}
|
||||
{stop.year != null ? ` (${stop.year})` : ''}
|
||||
</span>
|
||||
<div className="tours-stop-move">
|
||||
<button type="button" onClick={() => moveStop(index, -1)} disabled={index === 0}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStop(index, 1)}
|
||||
disabled={index === stops.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => setStops((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={stop.body}
|
||||
onChange={(e) =>
|
||||
setStops((prev) =>
|
||||
prev.map((s, i) => (i === index ? { ...s, body: e.target.value } : s)),
|
||||
)
|
||||
}
|
||||
rows={4}
|
||||
placeholder={t('stopBodyPlaceholder')}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{stops.length === 0 && <li className="muted">{t('noStops')}</li>}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.translations-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.translations-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-coverage {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.translations-toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-toolbar select {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.translations-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.translations-list {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.translations-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.translations-list th,
|
||||
.translations-list td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.translations-row-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-editor {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.translations-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-field textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.translations-canonical {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.translations-error {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.translations-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type TranslationDetail, type TranslationWorklistItem } from '../api/client';
|
||||
import './TranslationsPage.css';
|
||||
|
||||
type EntityType = 'artist' | 'painting' | 'movement';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function TranslationsPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('translations');
|
||||
const [entityType, setEntityType] = useState<EntityType>('artist');
|
||||
const [items, setItems] = useState<TranslationWorklistItem[]>([]);
|
||||
const [coverage, setCoverage] = useState<Record<string, number> | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<TranslationDetail | null>(null);
|
||||
const [draftFields, setDraftFields] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cov, list] = await Promise.all([
|
||||
api.getTranslationCoverage('ru'),
|
||||
api.getTranslationWorklist(entityType, 'ru'),
|
||||
]);
|
||||
setCoverage(cov.coverage);
|
||||
setItems(list.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
}, [loadList]);
|
||||
|
||||
const openItem = async (entityId: number) => {
|
||||
setSelectedId(entityId);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getEntityTranslation(entityType, entityId);
|
||||
setDetail(data);
|
||||
const ruFields: Record<string, string> = {};
|
||||
for (const field of data.translatableFields) {
|
||||
const row = data.translations.find((tr) => tr.locale === 'ru' && tr.field_name === field);
|
||||
ruFields[field] = row?.value ?? '';
|
||||
}
|
||||
setDraftFields(ruFields);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveEntityTranslation(entityType, selectedId, {
|
||||
locale: 'ru',
|
||||
fields: draftFields,
|
||||
status: 'draft',
|
||||
});
|
||||
await api.publishEntityTranslation(entityType, selectedId, 'ru');
|
||||
await loadList();
|
||||
await openItem(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="translations-page">
|
||||
<header className="translations-header">
|
||||
<button type="button" className="translations-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{coverage && (
|
||||
<div className="translations-coverage">
|
||||
<strong>{t('coverage')}:</strong>{' '}
|
||||
{t('artistsBio')}: {coverage.artists_bio_full}/{coverage.artists_total} ·{' '}
|
||||
{t('paintingsTitle')}: {coverage.paintings_title}/{coverage.paintings_total} ·{' '}
|
||||
{t('published')}: {coverage.published_count} · {t('draft')}: {coverage.draft_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="translations-toolbar">
|
||||
<label>
|
||||
{t('entityType')}
|
||||
<select value={entityType} onChange={(e) => setEntityType(e.target.value as EntityType)}>
|
||||
<option value="artist">artist</option>
|
||||
<option value="painting">painting</option>
|
||||
<option value="movement">movement</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="translations-error">{error}</p>}
|
||||
{loading && <p>{t('loadFailed')}…</p>}
|
||||
|
||||
<div className="translations-layout">
|
||||
<div className="translations-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{t('canonical')}</th>
|
||||
<th>{t('status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.entityId}
|
||||
className={selectedId === item.entityId ? 'translations-row-selected' : ''}
|
||||
onClick={() => void openItem(item.entityId)}
|
||||
>
|
||||
<td>{item.entityId}</td>
|
||||
<td>{item.label}</td>
|
||||
<td>
|
||||
{item.publishedCount} / {item.draftCount} draft · {item.missingFields.length} missing
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && items.length === 0 && <p>{t('noRows')}</p>}
|
||||
</div>
|
||||
|
||||
{detail && selectedId && (
|
||||
<div className="translations-editor">
|
||||
<h2>{String(detail.canonical.name || detail.canonical.title || selectedId)}</h2>
|
||||
{detail.translatableFields.map((field) => (
|
||||
<div key={field} className="translations-field">
|
||||
<label>{field}</label>
|
||||
<p className="translations-canonical">
|
||||
<strong>{t('canonical')}:</strong>{' '}
|
||||
{String(detail.canonical[field] ?? '')}
|
||||
</p>
|
||||
<textarea
|
||||
rows={field.includes('bio') || field === 'body' ? 8 : 3}
|
||||
value={draftFields[field] ?? ''}
|
||||
onChange={(e) => setDraftFields((prev) => ({ ...prev, [field]: e.target.value }))}
|
||||
placeholder={t('translation')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={saving} onClick={() => void saveDraft()}>
|
||||
{saving ? '…' : t('publish')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,11 @@ export interface Artist {
|
||||
movement_name?: string;
|
||||
movement_color?: string;
|
||||
portrait_path: string | null;
|
||||
bio_short: string;
|
||||
bio_full: string;
|
||||
portrait_thumb_path?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
bio_short?: string;
|
||||
bio_full?: string;
|
||||
wikipedia_title: string;
|
||||
century: number;
|
||||
checkup_checked?: boolean;
|
||||
@@ -59,6 +62,8 @@ export interface Painting {
|
||||
description: string;
|
||||
image_path: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
wikipedia_title: string;
|
||||
sort_order: number;
|
||||
artist_name?: string;
|
||||
@@ -128,11 +133,78 @@ export interface MovementGalleryDetail {
|
||||
paintings: Painting[];
|
||||
}
|
||||
|
||||
export interface TourSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'draft' | 'published';
|
||||
coverPaintingId: number | null;
|
||||
coverThumbnailPath: string | null;
|
||||
coverImagePath: string | null;
|
||||
stopCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TourGalleryDetail {
|
||||
tour: TourSummary;
|
||||
paintings: Painting[];
|
||||
stopBodies: Record<number, string>;
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
eras: HistoricalEra[];
|
||||
movements: ArtMovement[];
|
||||
}
|
||||
|
||||
export interface CatalogBootstrap extends TimelineData {
|
||||
bounds: YearBounds;
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export interface CatalogSearchArtistResult {
|
||||
type: 'artist';
|
||||
id: number;
|
||||
name: string;
|
||||
birth_year: number | null;
|
||||
death_year: number | null;
|
||||
movement_name: string | null;
|
||||
portrait_path: string | null;
|
||||
portrait_thumb_path: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchMovementResult {
|
||||
type: 'movement';
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
era_name: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchPaintingResult {
|
||||
type: 'painting';
|
||||
id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
artist_id: number;
|
||||
artist_name: string;
|
||||
movement_name: string | null;
|
||||
thumbnail_path: string | null;
|
||||
image_path: string | null;
|
||||
}
|
||||
|
||||
export type CatalogSearchResult =
|
||||
| CatalogSearchArtistResult
|
||||
| CatalogSearchMovementResult
|
||||
| CatalogSearchPaintingResult;
|
||||
|
||||
export interface CatalogSearchResponse {
|
||||
q: string;
|
||||
results: CatalogSearchResult[];
|
||||
}
|
||||
|
||||
export interface YearBounds {
|
||||
min_year: number;
|
||||
max_year: number;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const LOCALE_STORAGE_KEY = 'gallery_locale';
|
||||
export const SUPPORTED_LOCALES = ['en', 'ru'] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export function readStoredLocale(): AppLocale {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored === 'ru' || stored === 'en') return stored;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const nav = typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||
return nav.toLowerCase().startsWith('ru') ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
export function writeStoredLocale(locale: AppLocale) {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
export function localeQuery(locale: AppLocale): string {
|
||||
return locale === 'en' ? '' : `locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
|
||||
export function withLocale(url: string, locale: AppLocale): string {
|
||||
if (locale === 'en') return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Painting } from '../types';
|
||||
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import { comparePaintingsChronological } from './paintingUtils';
|
||||
|
||||
/** Target capacity per movement wing (50–60 works). */
|
||||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||||
@@ -92,10 +91,6 @@ function layoutRow(paintings: Painting[], span: number) {
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||
}
|
||||
|
||||
function orderForWall(paintings: Painting[]) {
|
||||
return [...paintings].sort(comparePaintingsChronological).reverse();
|
||||
}
|
||||
|
||||
function formatYearLabel(paintings: Painting[]) {
|
||||
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
|
||||
if (years.length === 0) return 'Undated works';
|
||||
@@ -104,22 +99,27 @@ function formatYearLabel(paintings: Painting[]) {
|
||||
return min === max ? `${min}` : `${min} – ${max}`;
|
||||
}
|
||||
|
||||
/** Preserve caller order (chrono for movements, stop order for tours). */
|
||||
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
if (sorted.length === 0) return [[]];
|
||||
if (paintings.length === 0) return [[]];
|
||||
const chunks: Painting[][] = [];
|
||||
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||||
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||||
for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||||
chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* First half → left wall, second half → right.
|
||||
* Callers pass paintings already in visit order; first work hangs near the
|
||||
* entrance on the left, last work near the entrance on the right.
|
||||
*/
|
||||
function distributeToSideWalls(paintings: Painting[]) {
|
||||
const left: Painting[] = [];
|
||||
const right: Painting[] = [];
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
|
||||
return { left: orderForWall(left), right: orderForWall(right) };
|
||||
const mid = Math.ceil(paintings.length / 2);
|
||||
return {
|
||||
left: paintings.slice(0, mid),
|
||||
right: paintings.slice(mid),
|
||||
};
|
||||
}
|
||||
|
||||
function layoutSideSlots(
|
||||
@@ -132,16 +132,21 @@ function layoutSideSlots(
|
||||
if (paintings.length === 0) return [];
|
||||
const { slots: rowSlots } = layoutRow(paintings, span);
|
||||
const y = EYE_HEIGHT;
|
||||
return rowSlots.map((s) => ({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
side,
|
||||
position:
|
||||
side === 'left'
|
||||
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
|
||||
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
|
||||
}));
|
||||
return rowSlots.map((s) => {
|
||||
// layoutRow places index 0 at negative offset. Flip on the left wall so
|
||||
// the first painting sits at the entrance (+Z), left of the starting view.
|
||||
const alongWall = side === 'left' ? -s.offset : s.offset;
|
||||
return {
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
side,
|
||||
position:
|
||||
side === 'left'
|
||||
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
|
||||
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMovementHallLayout(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
const MAX_CONCURRENT = 8;
|
||||
const queue: Array<() => void> = [];
|
||||
let inFlight = 0;
|
||||
|
||||
function pumpQueue() {
|
||||
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
|
||||
export function loadTextureQueued(url: string): Promise<THREE.Texture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = () => {
|
||||
inFlight++;
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
resolve(tex);
|
||||
},
|
||||
undefined,
|
||||
(err) => {
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
queue.push(task);
|
||||
pumpQueue();
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,45 @@
|
||||
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
|
||||
|
||||
/** "Nice" year steps for axis labels (ascending). */
|
||||
const TICK_INTERVALS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000];
|
||||
|
||||
/** Pick a label interval from span + pixel width so dates do not overlap. */
|
||||
export function chooseTimelineTickInterval(
|
||||
spanYears: number,
|
||||
widthPx: number,
|
||||
minLabelGapPx = 76
|
||||
): number {
|
||||
if (spanYears <= 0) return 1;
|
||||
const width = Math.max(widthPx, 320);
|
||||
const minYears = (spanYears / width) * minLabelGapPx;
|
||||
|
||||
let chosen = TICK_INTERVALS[TICK_INTERVALS.length - 1];
|
||||
for (const interval of TICK_INTERVALS) {
|
||||
if (interval >= minYears) {
|
||||
chosen = interval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap — never more than ~16 labelled ticks
|
||||
while (spanYears / chosen > 16) {
|
||||
const idx = TICK_INTERVALS.indexOf(chosen);
|
||||
if (idx < 0 || idx >= TICK_INTERVALS.length - 1) break;
|
||||
chosen = TICK_INTERVALS[idx + 1];
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function buildTimelineTickYears(viewStart: number, viewEnd: number, interval: number): number[] {
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / interval) * interval;
|
||||
for (let y = firstTick; y <= viewEnd; y += interval) {
|
||||
ticks.push(y);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function zoomTimelineView(
|
||||
clientX: number,
|
||||
rectLeft: number,
|
||||
@@ -49,3 +90,38 @@ export function panTimelineView(
|
||||
}
|
||||
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||
}
|
||||
|
||||
/** Coalesce rapid view updates to one React commit per animation frame. */
|
||||
export function createViewChangeScheduler(onApply: (start: number, end: number) => void) {
|
||||
let rafId: number | null = null;
|
||||
let pending: { start: number; end: number } | null = null;
|
||||
|
||||
return {
|
||||
schedule(start: number, end: number) {
|
||||
pending = { start, end };
|
||||
if (rafId != null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const next = pending;
|
||||
pending = null;
|
||||
if (next) onApply(next.start, next.end);
|
||||
});
|
||||
},
|
||||
flush() {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pending) {
|
||||
const next = pending;
|
||||
pending = null;
|
||||
onApply(next.start, next.end);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['devgallery.mysuperlab.netcraze.pro', 'gallery.mysuperlab.netcraze.pro', 'localhost'],
|
||||
proxy: {
|
||||
'/api': apiTarget,
|
||||
'/images': apiTarget,
|
||||
|
||||
Reference in New Issue
Block a user