DELETE /api/paintings/:id removes works and image files with gallery refresh and catalog navigation; Show more opens the search modal on load; documentation updated for migrate schema and debug workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||
import type { Artist } from '../types';
|
||
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;
|
||
debugShowMore?: 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,
|
||
debugMode = false,
|
||
debugShowMore = 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}`
|
||
: artist.birth_year
|
||
? `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 = useCallback(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);
|
||
}
|
||
}, [artist.id]);
|
||
|
||
useEffect(() => {
|
||
if (!debugMode || !debugShowMore) return;
|
||
void handleOpenMore();
|
||
}, [debugMode, debugShowMore, artist.id, handleOpenMore]);
|
||
|
||
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">
|
||
<button className="back-btn" onClick={onBack}>← Back</button>
|
||
<h1>{artist.name}</h1>
|
||
<button className="gallery-btn" onClick={onEnterGallery}>Enter Gallery</button>
|
||
</header>
|
||
|
||
<div className="bio-content">
|
||
<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">
|
||
<div className="bio-meta">
|
||
{lifespan && <span className="bio-lifespan">{lifespan}</span>}
|
||
{artist.movement_name && (
|
||
<span className="bio-movement">{artist.movement_name}</span>
|
||
)}
|
||
</div>
|
||
|
||
{artist.bio_short && (
|
||
<p className="bio-summary">{artist.bio_short}</p>
|
||
)}
|
||
|
||
{artist.bio_full && (
|
||
<div className="bio-full">
|
||
{artist.bio_full.split('\n').map((para, i) => (
|
||
<p key={i}>{para}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!artist.bio_full && !artist.bio_short && (
|
||
<p className="bio-empty">Biographical information not yet available.</p>
|
||
)}
|
||
|
||
{artist.wikipedia_title && (
|
||
<p className="bio-source">
|
||
Information sourced from Wikipedia article: {artist.wikipedia_title}
|
||
</p>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|