This commit is contained in:
Danila Khodjaef
2026-07-22 23:14:09 +03:00
482 changed files with 19800 additions and 1037 deletions
+1 -1
View File
@@ -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>
+76 -1
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
.artist-bio {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #e8d5b5;
+53 -37
View File
@@ -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}
+168
View File
@@ -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);
}
}
+278
View File
@@ -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);
}
+132
View File
@@ -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>
);
}
+26
View File
@@ -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;
}
+43
View File
@@ -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>
);
}
+59 -3
View File
@@ -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
+12 -9
View File
@@ -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>
+77 -2
View File
@@ -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;
}
+95 -57
View File
@@ -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) : '']
+35 -5
View File
@@ -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) => {
+144
View File
@@ -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);
}
+98
View File
@@ -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>
);
}
+211 -102
View File
@@ -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 &amp; right walls up to ~55 per wing</li>
+65
View File
@@ -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;
}
+54
View File
@@ -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;
}
+69
View File
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Art history notes",
"readSource": "Read source",
"categoryTechnique": "Technique",
"categoryComposition": "Composition",
"categorySubject": "Subject",
"categorySymbolism": "Symbolism",
"categoryHistorical": "Historical context",
"categoryOther": "Notes"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Enter Gallery",
"noBio": "No biography available yet.",
"wikipediaSource": "Text adapted from Wikipedia",
"backToTimeline": "← Back to Timeline"
}
+13
View File
@@ -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"
}
+14
View File
@@ -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"
}
+14
View File
@@ -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"
}
+26
View File
@@ -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"
}
+70
View File
@@ -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"
}
+15
View File
@@ -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}}"
}
+10
View File
@@ -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"
}
+28
View File
@@ -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."
}
+20
View File
@@ -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"
}
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Историко-художественные заметки",
"readSource": "Источник",
"categoryTechnique": "Техника",
"categoryComposition": "Композиция",
"categorySubject": "Сюжет",
"categorySymbolism": "Символика",
"categoryHistorical": "Исторический контекст",
"categoryOther": "Заметки"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Войти в галерею",
"noBio": "Биография пока недоступна.",
"wikipediaSource": "Текст адаптирован из Википедии",
"backToTimeline": "← На шкалу времени"
}
+13
View File
@@ -0,0 +1,13 @@
{
"loading": "Загрузка…",
"close": "Закрыть",
"back": "Назад",
"save": "Сохранить",
"cancel": "Отмена",
"yes": "Да",
"no": "Нет",
"error": "Что-то пошло не так",
"localeEn": "EN",
"localeRu": "RU",
"language": "Язык"
}
+14
View File
@@ -0,0 +1,14 @@
{
"checked": "Проверено",
"fixIt": "Исправить",
"more": "Ещё",
"clear": "Очистить",
"upload": "Загрузить",
"searching": "Поиск…",
"saving": "Сохранение…",
"loginTitle": "Вход куратора",
"username": "Имя пользователя",
"password": "Пароль",
"signIn": "Войти",
"loginFailed": "Ошибка входа"
}
+14
View File
@@ -0,0 +1,14 @@
{
"backToTimeline": "← На шкалу времени",
"exitToTimeline": "Выход на шкалу времени",
"loadingGallery": "Загрузка галереи…",
"instructionsTitle": "Управление",
"instructionMove": "Перетаскивайте для обзора",
"instructionZoom": "Колёсико — масштаб",
"instructionClick": "Нажмите на картину для подробностей",
"wingOf": "Крыло {{current}} из {{total}}",
"exitConfirmTitle": "Покинуть галерею?",
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
"stay": "Остаться",
"exit": "Выход"
}
+26
View File
@@ -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": "Вернуться в галерею"
}
+70
View File
@@ -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}} рёбер"
}
+15
View File
@@ -0,0 +1,15 @@
{
"aboutArtist": "О {{name}}",
"influencedBy": "Под влиянием",
"influenced": "Влияние на",
"noInfluences": "Для этой работы нет задокументированных влияний.",
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
"catalogPosition": "Позиция в каталоге",
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
"prevPainting": "Предыдущая картина",
"nextPainting": "Следующая картина",
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}"
}
+10
View File
@@ -0,0 +1,10 @@
{
"label": "Поиск",
"placeholder": "Художники, картины, направления…",
"resultsLabel": "Результаты поиска",
"noMatches": "Ничего не найдено.",
"groupArtist": "Художники",
"groupMovement": "Направления",
"groupPainting": "Картины",
"searchFailed": "Ошибка поиска"
}
+28
View File
@@ -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": "Остановок пока нет. Найдите и добавьте картины."
}
+20
View File
@@ -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
View File
@@ -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>,
)
+39 -4
View File
@@ -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
View File
@@ -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>
</>
+378
View File
@@ -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;
}
+831
View File
@@ -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>
);
}
+180
View File
@@ -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;
}
}
+363
View File
@@ -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>
);
}
+103
View File
@@ -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;
}
}
+171
View File
@@ -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>
);
}
+74 -2
View File
@@ -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;
+29
View File
@@ -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)}`;
}
+29 -24
View File
@@ -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 (5060 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(
+39
View File
@@ -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();
});
}
+76
View File
@@ -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;
},
};
}
+1
View File
@@ -12,6 +12,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
+2
View File
@@ -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,