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:
Danila Khodjaef
2026-06-21 13:14:06 +03:00
co-authored by Cursor
parent b4425445bb
commit 0972b5df99
56 changed files with 1980 additions and 92 deletions
+13
View File
@@ -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;
}
+297 -10
View File
@@ -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>
);
}
+4 -2
View File
@@ -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';
+78
View File
@@ -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;
+175 -23
View File
@@ -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}