Add debug More/Clear/Upload tools for paintings and artist portraits.
Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b4425445bb
commit
0972b5df99
+134
-3
@@ -20,6 +20,12 @@ export function imageUrl(path: string | null | undefined): string {
|
||||
return `/images/${path}`;
|
||||
}
|
||||
|
||||
export function portraitUrl(path: string | null | undefined, revision?: number): string {
|
||||
const base = imageUrl(path);
|
||||
if (!revision || base.startsWith('/placeholder')) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
}
|
||||
|
||||
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
|
||||
export function galleryImageUrl(painting: {
|
||||
thumbnail_path?: string | null;
|
||||
@@ -47,12 +53,47 @@ export function paintingImageUrl(painting: {
|
||||
id: number;
|
||||
image_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
}): string {
|
||||
checkup_fixed?: boolean;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
if (painting.checkup_fixed) return null;
|
||||
return `/api/paintings/${painting.id}/image?size=full`;
|
||||
}
|
||||
|
||||
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('Could not read file'));
|
||||
return;
|
||||
}
|
||||
const comma = result.indexOf(',');
|
||||
resolve({
|
||||
imageData: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Preload failed');
|
||||
@@ -60,8 +101,14 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
||||
}
|
||||
|
||||
export interface FixPaintingImageResult {
|
||||
imagePath: string;
|
||||
thumbnailPath: string;
|
||||
imagePath: string | null;
|
||||
thumbnailPath: string | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface FixArtistPortraitResult {
|
||||
portraitPath: string | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
@@ -75,6 +122,20 @@ export interface DebugImageSearchResult {
|
||||
thumbUrl?: string;
|
||||
}
|
||||
|
||||
export interface DebugImageSearchResultItem {
|
||||
imageUrl: string;
|
||||
thumbUrl?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface DebugImageSearchManyResult {
|
||||
query: string;
|
||||
searchUrl: string;
|
||||
source: string;
|
||||
sourceLabel?: string;
|
||||
results: DebugImageSearchResultItem[];
|
||||
}
|
||||
|
||||
export interface PaintingCheckupRow {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -120,6 +181,9 @@ export const api = {
|
||||
getPaintingDebugImageSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||
|
||||
getPaintingDebugImageSearchMore: (id: number, limit = 20) =>
|
||||
fetchJson<DebugImageSearchManyResult>(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`),
|
||||
|
||||
fixPaintingImage: (
|
||||
id: number,
|
||||
imageUrl: string,
|
||||
@@ -137,6 +201,20 @@ export const api = {
|
||||
return res.json() as Promise<FixPaintingImageResult>;
|
||||
}),
|
||||
|
||||
clearPaintingImage: (id: number) =>
|
||||
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<FixPaintingImageResult>;
|
||||
}),
|
||||
|
||||
uploadPaintingImage: async (id: number, file: File) => {
|
||||
const payload = await fileToBase64Payload(file);
|
||||
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
|
||||
},
|
||||
|
||||
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
||||
|
||||
updatePaintingCheckupFlags: (
|
||||
@@ -154,6 +232,59 @@ export const api = {
|
||||
}
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
getArtistDebugPortraitSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
|
||||
|
||||
getArtistDebugPortraitSearchMore: (id: number, limit = 20) =>
|
||||
fetchJson<DebugImageSearchManyResult>(`${API}/artists/${id}/debug-portrait-search/more?limit=${limit}`),
|
||||
|
||||
fixArtistPortrait: (
|
||||
id: number,
|
||||
imageUrl: string,
|
||||
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||
) =>
|
||||
fetch(`${API}/artists/${id}/fix-portrait`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl, ...context }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Fix failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<FixArtistPortraitResult>;
|
||||
}),
|
||||
|
||||
clearArtistPortrait: (id: number) =>
|
||||
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<FixArtistPortraitResult>;
|
||||
}),
|
||||
|
||||
uploadArtistPortrait: async (id: number, file: File) => {
|
||||
const payload = await fileToBase64Payload(file);
|
||||
return postJsonImageAction<FixArtistPortraitResult>(`${API}/artists/${id}/upload-portrait`, payload);
|
||||
},
|
||||
|
||||
updateArtistCheckupFlags: (
|
||||
id: number,
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/artists/${id}/checkup-flags`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
};
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
|
||||
@@ -58,6 +58,19 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bio-portrait-checked img {
|
||||
border-color: #ffd700;
|
||||
box-shadow: 0 8px 28px rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.bio-portrait-empty {
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
border: 4px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bio-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import type { Artist } from '../types';
|
||||
import { imageUrl } from '../api/client';
|
||||
import {
|
||||
api,
|
||||
debugImageProxyUrl,
|
||||
portraitUrl,
|
||||
type DebugImageSearchResult,
|
||||
type DebugImageSearchResultItem,
|
||||
type FixArtistPortraitResult,
|
||||
} from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import '../components/PaintingDetail.css';
|
||||
import './ArtistBio.css';
|
||||
|
||||
interface Props {
|
||||
artist: Artist & { movement_name?: string };
|
||||
debugMode?: boolean;
|
||||
portraitRevision?: number;
|
||||
onBack: () => void;
|
||||
onEnterGallery: () => void;
|
||||
onArtistPortraitFixed?: (
|
||||
artistId: number,
|
||||
fixResult: FixArtistPortraitResult
|
||||
) => void | Promise<void>;
|
||||
onArtistCheckupFlagsUpdated?: (
|
||||
artistId: number,
|
||||
flags: { checked: boolean; fixed: boolean }
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
export default function ArtistBio({
|
||||
artist,
|
||||
debugMode = false,
|
||||
portraitRevision = 0,
|
||||
onBack,
|
||||
onEnterGallery,
|
||||
onArtistPortraitFixed,
|
||||
onArtistCheckupFlagsUpdated,
|
||||
}: Props) {
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
const [debugLoading, setDebugLoading] = useState(false);
|
||||
const [debugError, setDebugError] = useState<string | null>(null);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const [markingChecked, setMarkingChecked] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [moreLoading, setMoreLoading] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getArtistDebugPortraitSearchMore>> | null>(null);
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(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 lifespan =
|
||||
artist.birth_year && artist.death_year
|
||||
? `${artist.birth_year} – ${artist.death_year}`
|
||||
@@ -16,6 +62,154 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
? `b. ${artist.birth_year}`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode) {
|
||||
setDebugSearch(null);
|
||||
setDebugError(null);
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
setDebugError(null);
|
||||
setDebugSearch(null);
|
||||
|
||||
api.getArtistDebugPortraitSearch(artist.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDebugSearch(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDebugError('Portrait image search failed.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDebugLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, artist.id, artist.name]);
|
||||
|
||||
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
|
||||
if (onArtistPortraitFixed) {
|
||||
await onArtistPortraitFixed(artist.id, fixResult);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFixFromSearch = async (
|
||||
imageUrl: string,
|
||||
context: { searchUrl: string; source: string; thumbUrl?: string }
|
||||
) => {
|
||||
const fixResult = await api.fixArtistPortrait(artist.id, imageUrl, context);
|
||||
await applyPortraitUpdate(fixResult);
|
||||
setDebugSearch((prev) =>
|
||||
prev
|
||||
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
const handleFixPortrait = async () => {
|
||||
if (!debugSearch?.imageUrl || fixing) return;
|
||||
setFixing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
await applyFixFromSearch(debugSearch.imageUrl, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
thumbUrl: debugSearch.thumbUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not replace portrait.');
|
||||
} finally {
|
||||
setFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMore = async () => {
|
||||
setMoreOpen(true);
|
||||
setMoreLoading(true);
|
||||
setMoreError(null);
|
||||
setMoreResults(null);
|
||||
try {
|
||||
const results = await api.getArtistDebugPortraitSearchMore(artist.id);
|
||||
setMoreResults(results);
|
||||
} catch {
|
||||
setMoreError('Could not load search results.');
|
||||
} finally {
|
||||
setMoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||
if (fixing || applyingUrl) return;
|
||||
setApplyingUrl(item.imageUrl);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
|
||||
await applyFixFromSearch(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
thumbUrl: item.thumbUrl,
|
||||
});
|
||||
setMoreOpen(false);
|
||||
} catch (err) {
|
||||
setMoreError(err instanceof Error ? err.message : 'Could not replace portrait.');
|
||||
} finally {
|
||||
setApplyingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkChecked = async () => {
|
||||
if (artist.checkup_checked || markingChecked) return;
|
||||
setMarkingChecked(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const updated = await api.updateArtistCheckupFlags(artist.id, { checked: true });
|
||||
await onArtistCheckupFlagsUpdated?.(artist.id, updated);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
|
||||
} finally {
|
||||
setMarkingChecked(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPortrait = async () => {
|
||||
if (clearing || fixing || uploading) return;
|
||||
setClearing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.clearArtistPortrait(artist.id);
|
||||
await applyPortraitUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not clear portrait.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.uploadArtistPortrait(artist.id, file);
|
||||
await applyPortraitUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="artist-bio">
|
||||
<header className="bio-header">
|
||||
@@ -25,14 +219,18 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
</header>
|
||||
|
||||
<div className="bio-content">
|
||||
<div className="bio-portrait">
|
||||
<img
|
||||
src={imageUrl(artist.portrait_path)}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
|
||||
>
|
||||
{showPortrait ? (
|
||||
<img
|
||||
src={portraitSrc}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bio-text">
|
||||
@@ -66,6 +264,95 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" 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 && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
})}
|
||||
alt={`Search result for ${debugSearch.query}`}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixPortrait}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-clear-btn"
|
||||
onClick={handleClearPortrait}
|
||||
disabled={clearing || fixing || uploading || portraitCleared}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
title="Choose portrait"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
error={moreError}
|
||||
applyingUrl={applyingUrl}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
.debug-search-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.debug-search-modal {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(88vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(232, 160, 64, 0.45);
|
||||
background: rgba(15, 15, 26, 0.98);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.debug-search-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.debug-search-modal-header h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e8a040;
|
||||
}
|
||||
|
||||
.debug-search-modal-query {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.debug-search-modal-close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.4);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e8d5b5;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-search-modal-close:hover {
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
}
|
||||
|
||||
.debug-search-modal-hint,
|
||||
.debug-search-modal-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
}
|
||||
|
||||
.debug-search-modal-error {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.debug-search-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
max-height: min(68vh, 720px);
|
||||
}
|
||||
|
||||
.debug-search-modal-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 2px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
background: #2a1f15;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-search-modal-item:hover:not(:disabled) {
|
||||
border-color: #e8a040;
|
||||
box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35);
|
||||
}
|
||||
|
||||
.debug-search-modal-item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-busy {
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.debug-search-modal-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-busy-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from 'react';
|
||||
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
|
||||
import './DebugSearchResultsModal.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
data: DebugImageSearchManyResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
applyingUrl: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (item: DebugImageSearchResultItem) => void;
|
||||
}
|
||||
|
||||
export default function DebugSearchResultsModal({
|
||||
open,
|
||||
title,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
applyingUrl,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="debug-search-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="debug-search-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="debug-search-modal-header">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{data?.query && <p className="debug-search-modal-query">{data.query}</p>}
|
||||
</div>
|
||||
<button type="button" className="debug-search-modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading && <p className="debug-search-modal-status">Loading results…</p>}
|
||||
{error && <p className="debug-search-modal-error">{error}</p>}
|
||||
|
||||
{!loading && !error && data && data.results.length === 0 && (
|
||||
<p className="debug-search-modal-status">No images found.</p>
|
||||
)}
|
||||
|
||||
{!loading && data && data.results.length > 0 && (
|
||||
<>
|
||||
<p className="debug-search-modal-hint">Click an image to replace the current one.</p>
|
||||
<div className="debug-search-modal-grid">
|
||||
{data.results.map((item, index) => {
|
||||
const busy = applyingUrl === item.imageUrl;
|
||||
return (
|
||||
<button
|
||||
key={`${item.imageUrl}-${index}`}
|
||||
type="button"
|
||||
className={`debug-search-modal-item${busy ? ' debug-search-modal-item-busy' : ''}`}
|
||||
disabled={!!applyingUrl}
|
||||
onClick={() => onSelect(item)}
|
||||
title="Use this image"
|
||||
>
|
||||
<img
|
||||
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
|
||||
searchUrl: data.searchUrl,
|
||||
source: item.source,
|
||||
})}
|
||||
alt={`Result ${index + 1}`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="debug-search-modal-item-index">{index + 1}</span>
|
||||
{busy && <span className="debug-search-modal-item-busy-label">Saving…</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import type { ArtMovement, Artist } from '../types';
|
||||
import { imageUrl } from '../api/client';
|
||||
import { portraitUrl } from '../api/client';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementBands.css';
|
||||
@@ -8,6 +8,7 @@ import './MovementBands.css';
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
artists: Artist[];
|
||||
portraitRevisions?: Record<number, number>;
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
@@ -325,6 +326,7 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||
export default function MovementBands({
|
||||
movements,
|
||||
artists,
|
||||
portraitRevisions,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
@@ -770,7 +772,7 @@ export default function MovementBands({
|
||||
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||
>
|
||||
<img
|
||||
src={imageUrl(artist.portrait_path)}
|
||||
src={portraitUrl(artist.portrait_path, portraitRevisions?.[artist.id])}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
|
||||
@@ -499,6 +499,84 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.debug-more-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.55);
|
||||
border-radius: 6px;
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
color: #e8d5b5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-more-btn:hover:not(:disabled) {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
border-color: #c9a96e;
|
||||
}
|
||||
|
||||
.debug-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.debug-action-buttons-secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.debug-clear-btn,
|
||||
.debug-upload-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-clear-btn {
|
||||
border: 1px solid rgba(232, 120, 100, 0.55);
|
||||
background: rgba(232, 120, 100, 0.1);
|
||||
color: #e87864;
|
||||
}
|
||||
|
||||
.debug-clear-btn:hover:not(:disabled) {
|
||||
background: rgba(232, 120, 100, 0.2);
|
||||
border-color: #e87864;
|
||||
}
|
||||
|
||||
.debug-upload-btn {
|
||||
border: 1px solid rgba(140, 190, 140, 0.55);
|
||||
background: rgba(140, 190, 140, 0.1);
|
||||
color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-upload-btn:hover:not(:disabled) {
|
||||
background: rgba(140, 190, 140, 0.2);
|
||||
border-color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-clear-btn:disabled,
|
||||
.debug-upload-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painting-frame-empty {
|
||||
min-height: 280px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.painting-frame-empty:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0 0 2px #c9a96e;
|
||||
}
|
||||
|
||||
.painting-frame-cleared {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.debug-image-status {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import PaintingLightbox from './PaintingLightbox';
|
||||
import './PaintingDetail.css';
|
||||
|
||||
@@ -158,7 +159,7 @@ function InfluenceCard({
|
||||
title={`View ${inf.title}`}
|
||||
>
|
||||
<img
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
|
||||
alt={inf.title || 'Painting'}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
@@ -197,8 +198,20 @@ export default function PaintingDetailView({
|
||||
const [debugError, setDebugError] = useState<string | null>(null);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const [markingChecked, setMarkingChecked] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [moreLoading, setMoreLoading] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getPaintingDebugImageSearchMore>> | null>(null);
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
|
||||
const baseImageUrl = paintingImageUrl(painting);
|
||||
const imageSrc = baseImageUrl
|
||||
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
|
||||
: null;
|
||||
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
|
||||
|
||||
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||
@@ -211,12 +224,16 @@ export default function PaintingDetailView({
|
||||
useEffect(() => {
|
||||
setFullscreen(false);
|
||||
setImageVersion(0);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
}, [painting.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode) {
|
||||
setDebugSearch(null);
|
||||
setDebugError(null);
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,20 +258,36 @@ export default function PaintingDetailView({
|
||||
};
|
||||
}, [debugMode, painting.id, painting.title, painting.artist_name]);
|
||||
|
||||
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
|
||||
setImageVersion((v) => v + 1);
|
||||
if (onPaintingImageFixed) {
|
||||
await onPaintingImageFixed(painting.id, fixResult);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFixFromSearch = async (
|
||||
imageUrl: string,
|
||||
context: { searchUrl: string; source: string; thumbUrl?: string }
|
||||
) => {
|
||||
const fixResult = await api.fixPaintingImage(painting.id, imageUrl, context);
|
||||
await applyImageUpdate(fixResult);
|
||||
setDebugSearch((prev) =>
|
||||
prev
|
||||
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
const handleFixImage = async () => {
|
||||
if (!debugSearch?.imageUrl || fixing) return;
|
||||
setFixing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
|
||||
await applyFixFromSearch(debugSearch.imageUrl, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
thumbUrl: debugSearch.thumbUrl,
|
||||
});
|
||||
setImageVersion((v) => v + 1);
|
||||
if (onPaintingImageFixed) {
|
||||
await onPaintingImageFixed(painting.id, fixResult);
|
||||
}
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||
} finally {
|
||||
@@ -262,6 +295,41 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMore = async () => {
|
||||
setMoreOpen(true);
|
||||
setMoreLoading(true);
|
||||
setMoreError(null);
|
||||
setMoreResults(null);
|
||||
try {
|
||||
const results = await api.getPaintingDebugImageSearchMore(painting.id);
|
||||
setMoreResults(results);
|
||||
} catch {
|
||||
setMoreError('Could not load search results.');
|
||||
} finally {
|
||||
setMoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||
if (fixing || applyingUrl) return;
|
||||
setApplyingUrl(item.imageUrl);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
|
||||
await applyFixFromSearch(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
thumbUrl: item.thumbUrl,
|
||||
});
|
||||
setMoreOpen(false);
|
||||
} catch (err) {
|
||||
setMoreError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||
} finally {
|
||||
setApplyingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkChecked = async () => {
|
||||
if (painting.checkup_checked || markingChecked) return;
|
||||
setMarkingChecked(true);
|
||||
@@ -276,6 +344,40 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearImage = async () => {
|
||||
if (clearing || fixing || uploading) return;
|
||||
setClearing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.clearPaintingImage(painting.id);
|
||||
await applyImageUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not clear image.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.uploadPaintingImage(painting.id, file);
|
||||
await applyImageUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fullscreen) return;
|
||||
|
||||
@@ -356,20 +458,26 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<div
|
||||
className="painting-frame-large painting-frame-clickable"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setFullscreen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFullscreen(true);
|
||||
}
|
||||
}}
|
||||
title="View full screen"
|
||||
aria-label={`View ${painting.title} full screen`}
|
||||
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}
|
||||
onKeyDown={
|
||||
imageSrc
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFullscreen(true);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={imageSrc ? 'View full screen' : undefined}
|
||||
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showCatalogNav && (
|
||||
@@ -452,11 +560,55 @@ export default function PaintingDetailView({
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-clear-btn"
|
||||
onClick={handleClearImage}
|
||||
disabled={clearing || fixing || uploading || imageCleared}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{fullscreen && (
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
title="Choose painting image"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
error={moreError}
|
||||
applyingUrl={applyingUrl}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
|
||||
{fullscreen && imageSrc && (
|
||||
<PaintingLightbox
|
||||
src={imageSrc}
|
||||
alt={painting.title}
|
||||
|
||||
@@ -348,7 +348,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
|
||||
setRows((list) =>
|
||||
list.map((r) =>
|
||||
r.id === row.id
|
||||
r.id === row.id && updated.imagePath && updated.thumbnailPath
|
||||
? {
|
||||
...r,
|
||||
gallery_file: updated.imagePath,
|
||||
|
||||
@@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import { api, type FixPaintingImageResult } from '../api/client';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||
@@ -29,6 +29,13 @@ function patchPaintingInArtistDetail(
|
||||
};
|
||||
}
|
||||
|
||||
function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>): ArtistDetail {
|
||||
return {
|
||||
...detail,
|
||||
artist: { ...detail.artist, ...patch },
|
||||
};
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
||||
@@ -43,6 +50,7 @@ export default function HomePage() {
|
||||
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 [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
@@ -103,8 +111,8 @@ export default function HomePage() {
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
image_path: fixResult.imagePath ?? data.painting.image_path,
|
||||
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
|
||||
image_path: fixResult.imagePath,
|
||||
thumbnail_path: fixResult.thumbnailPath,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
@@ -175,6 +183,73 @@ export default function HomePage() {
|
||||
[]
|
||||
);
|
||||
|
||||
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
|
||||
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
|
||||
|
||||
setView((current) => {
|
||||
if (current.type === 'bio' && current.artistId === artistId) {
|
||||
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
|
||||
}
|
||||
if (current.type === 'gallery' && current.artistId === artistId) {
|
||||
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
|
||||
}
|
||||
if (current.type === 'painting' && current.data.painting.artist_id === artistId) {
|
||||
return {
|
||||
...current,
|
||||
data: {
|
||||
...current.data,
|
||||
painting: {
|
||||
...current.data.painting,
|
||||
artist_portrait: patch.portrait_path ?? current.data.painting.artist_portrait,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
setGallerySession((session) =>
|
||||
session && session.artistId === artistId
|
||||
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
|
||||
: session
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleArtistPortraitFixed = useCallback(
|
||||
async (artistId: number, fixResult: FixArtistPortraitResult) => {
|
||||
const data = await api.getArtist(artistId);
|
||||
const patch: Partial<Artist> = {
|
||||
portrait_path: fixResult.portraitPath,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
setPortraitRevisions((prev) => ({ ...prev, [artistId]: (prev[artistId] ?? 0) + 1 }));
|
||||
applyArtistPatch(artistId, patch);
|
||||
setView((current) =>
|
||||
current.type === 'bio' && current.artistId === artistId
|
||||
? { ...current, data: { ...data, artist: { ...data.artist, ...patch } } }
|
||||
: current
|
||||
);
|
||||
},
|
||||
[applyArtistPatch]
|
||||
);
|
||||
|
||||
const handleArtistCheckupFlagsUpdated = useCallback(
|
||||
async (artistId: number, flags: { checked: boolean; fixed: boolean }) => {
|
||||
const patch: Partial<Artist> = {
|
||||
checkup_checked: flags.checked,
|
||||
checkup_fixed: flags.fixed,
|
||||
};
|
||||
applyArtistPatch(artistId, patch);
|
||||
setView((current) =>
|
||||
current.type === 'bio' && current.artistId === artistId
|
||||
? { ...current, data: patchArtistInArtistDetail(current.data, patch) }
|
||||
: current
|
||||
);
|
||||
},
|
||||
[applyArtistPatch]
|
||||
);
|
||||
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
try {
|
||||
const data = await api.getArtist(artistId);
|
||||
@@ -305,10 +380,14 @@ export default function HomePage() {
|
||||
<div className="home-overlay">
|
||||
<ArtistBio
|
||||
artist={view.data.artist}
|
||||
debugMode={debugMode}
|
||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||
onBack={() => setView(view.returnTo)}
|
||||
onEnterGallery={() =>
|
||||
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
||||
}
|
||||
onArtistPortraitFixed={handleArtistPortraitFixed}
|
||||
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -328,7 +407,7 @@ export default function HomePage() {
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details"
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
@@ -363,6 +442,7 @@ export default function HomePage() {
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
|
||||
@@ -30,11 +30,13 @@ export interface Artist {
|
||||
movement_id: number;
|
||||
movement_name?: string;
|
||||
movement_color?: string;
|
||||
portrait_path: string;
|
||||
portrait_path: string | null;
|
||||
bio_short: string;
|
||||
bio_full: string;
|
||||
wikipedia_title: string;
|
||||
century: number;
|
||||
checkup_checked?: boolean;
|
||||
checkup_fixed?: boolean;
|
||||
}
|
||||
|
||||
export interface ArtistPeriod {
|
||||
@@ -55,8 +57,8 @@ export interface Painting {
|
||||
year: number;
|
||||
year_end?: number;
|
||||
description: string;
|
||||
image_path: string;
|
||||
thumbnail_path?: string;
|
||||
image_path: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
wikipedia_title: string;
|
||||
sort_order: number;
|
||||
artist_name?: string;
|
||||
@@ -95,7 +97,7 @@ export interface InfluenceLink {
|
||||
}
|
||||
|
||||
export interface PaintingDetail {
|
||||
painting: Painting & { artist_name: string; artist_portrait: string };
|
||||
painting: Painting & { artist_name: string; artist_portrait: string | null };
|
||||
influencedBy: InfluenceLink[];
|
||||
influenced: InfluenceLink[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user