Files
Art-gallery/client/src/components/ArtistBio.tsx
T
Danila KhodjaefandCursor f542c689c9 Add debug Remove entry, Show more auto-picker, and update docs.
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>
2026-06-22 22:32:23 +03:00

366 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}