Movement galleries split large catalogs into chronological wings (~55 works), use era-themed 3D interiors with side-wall windows, wing navigator on the back exit, and front archways between wings. Also adds painting annotations, timeline event guides, portrait hover highlights, and documentation/API updates. Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
4.7 KiB
TypeScript
155 lines
4.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
|
||
import './DebugSearchResultsModal.css';
|
||
|
||
function formatResolution(width?: number, height?: number): string | null {
|
||
if (!width || !height || width <= 0 || height <= 0) return null;
|
||
return `${width} × ${height}`;
|
||
}
|
||
|
||
function ResultResolution({
|
||
item,
|
||
searchUrl,
|
||
}: {
|
||
item: DebugImageSearchResultItem;
|
||
searchUrl: string;
|
||
}) {
|
||
const initial = formatResolution(item.width, item.height);
|
||
const [label, setLabel] = useState<string | null>(initial);
|
||
|
||
useEffect(() => {
|
||
if (initial) {
|
||
setLabel(initial);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (cancelled) return;
|
||
const text = formatResolution(img.naturalWidth, img.naturalHeight);
|
||
setLabel(text ?? '—');
|
||
};
|
||
img.onerror = () => {
|
||
if (!cancelled) setLabel('—');
|
||
};
|
||
img.src = debugImageProxyUrl(item.imageUrl, {
|
||
searchUrl,
|
||
source: item.source,
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
img.onload = null;
|
||
img.onerror = null;
|
||
};
|
||
}, [item.imageUrl, item.source, item.width, item.height, searchUrl, initial]);
|
||
|
||
return (
|
||
<span className="debug-search-modal-item-resolution" aria-hidden="true">
|
||
{label ?? '…'}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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"
|
||
>
|
||
<span className="debug-search-modal-item-media">
|
||
<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>}
|
||
</span>
|
||
<ResultResolution item={item} searchUrl={data.searchUrl} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|