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
+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}