Add checkup review flags, duplicate detection, and corrected painting images.
Introduce checkup_checked/checkup_fixed on paintings with Checkup page filters and API. Fixed paintings auto-mark as reviewed. Add find-duplicates tooling and document debug/checkup workflow. Include Botticelli and Michelangelo image fixes from checkup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4683089495
commit
4c6acd5a3a
@@ -67,6 +67,8 @@ export interface PaintingCheckupRow {
|
||||
detail_on_demand: boolean;
|
||||
gallery_file_exists: boolean;
|
||||
detail_file_exists: boolean | null;
|
||||
checked: boolean;
|
||||
fixed: boolean;
|
||||
}
|
||||
|
||||
export interface PaintingCheckupData {
|
||||
@@ -108,10 +110,26 @@ export const api = {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Fix failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ imagePath: string; thumbnailPath: string }>;
|
||||
return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>;
|
||||
}),
|
||||
|
||||
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
||||
|
||||
updatePaintingCheckupFlags: (
|
||||
id: number,
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
};
|
||||
|
||||
export function debugImageProxyUrl(imageUrl: string): string {
|
||||
|
||||
@@ -65,6 +65,80 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.checkup-search-visible-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e8a040;
|
||||
background: rgba(232, 160, 64, 0.15);
|
||||
color: #e8a040;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkup-search-visible-btn:hover:not(:disabled) {
|
||||
background: rgba(232, 160, 64, 0.28);
|
||||
}
|
||||
|
||||
.checkup-search-visible-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.45);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.checkup-filter-select-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkup-filter-select {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
background: rgba(15, 15, 26, 0.9);
|
||||
color: #e8d5b5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkup-flag-cell {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.checkup-flag-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkup-flag-label input {
|
||||
accent-color: #e8a040;
|
||||
}
|
||||
|
||||
.checkup-row-checked {
|
||||
background: rgba(201, 169, 110, 0.04);
|
||||
}
|
||||
|
||||
.checkup-row-done {
|
||||
background: rgba(76, 175, 80, 0.06);
|
||||
}
|
||||
|
||||
.checkup-table tbody tr.checkup-row-checked:hover,
|
||||
.checkup-table tbody tr.checkup-row-done:hover {
|
||||
background: rgba(201, 169, 110, 0.1);
|
||||
}
|
||||
|
||||
.checkup-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -19,7 +19,6 @@ type RowSearchState =
|
||||
| { status: 'error' };
|
||||
|
||||
const SEARCH_CONCURRENCY = 3;
|
||||
const FILTER_SEARCH_DEBOUNCE_MS = 350;
|
||||
|
||||
function previewSrc(
|
||||
row: PaintingCheckupRow,
|
||||
@@ -88,9 +87,17 @@ function SearchPreviewCell({
|
||||
searchState,
|
||||
onRetry,
|
||||
}: {
|
||||
searchState: RowSearchState;
|
||||
searchState: RowSearchState | undefined;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (!searchState) {
|
||||
return (
|
||||
<div className="checkup-thumb checkup-thumb-search checkup-thumb-empty">
|
||||
<span>—</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchState.status === 'loading' || searchState.status === 'idle') {
|
||||
return (
|
||||
<div className="checkup-thumb checkup-thumb-search checkup-thumb-loading">
|
||||
@@ -144,13 +151,13 @@ function FixCell({
|
||||
fixError,
|
||||
onFix,
|
||||
}: {
|
||||
searchState: RowSearchState;
|
||||
searchState: RowSearchState | undefined;
|
||||
fixing: boolean;
|
||||
fixError: string | null;
|
||||
onFix: () => void;
|
||||
}) {
|
||||
const canFix =
|
||||
searchState.status === 'done' && !!searchState.result.imageUrl && !fixing;
|
||||
searchState?.status === 'done' && !!searchState.result.imageUrl && !fixing;
|
||||
|
||||
return (
|
||||
<div className="checkup-fix-cell">
|
||||
@@ -160,7 +167,7 @@ function FixCell({
|
||||
onClick={onFix}
|
||||
disabled={!canFix}
|
||||
title={
|
||||
searchState.status === 'done' && searchState.result.imageUrl
|
||||
searchState?.status === 'done' && searchState.result.imageUrl
|
||||
? 'Replace gallery and detail images with search result'
|
||||
: 'Wait for search result'
|
||||
}
|
||||
@@ -168,10 +175,10 @@ function FixCell({
|
||||
{fixing ? 'Replacing…' : 'Fix it'}
|
||||
</button>
|
||||
{fixError && <p className="checkup-fix-error">{fixError}</p>}
|
||||
{(searchState.status === 'loading' || searchState.status === 'idle') && (
|
||||
{searchState?.status === 'loading' || searchState?.status === 'idle' ? (
|
||||
<p className="checkup-fix-hint">Waiting for search…</p>
|
||||
)}
|
||||
{searchState.status === 'done' && !searchState.result.imageUrl && (
|
||||
) : null}
|
||||
{searchState?.status === 'done' && !searchState.result.imageUrl && (
|
||||
<p className="checkup-fix-hint">No image to apply</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,15 +189,44 @@ function needsSearch(state: RowSearchState | undefined): boolean {
|
||||
return !state || state.status === 'idle' || state.status === 'error';
|
||||
}
|
||||
|
||||
type FlagFilter = 'all' | 'yes' | 'no';
|
||||
|
||||
function FlagCheckbox({
|
||||
label,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="checkup-flag-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
const [rows, setRows] = useState<PaintingCheckupRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterChecked, setFilterChecked] = useState<FlagFilter>('all');
|
||||
const [filterFixed, setFilterFixed] = useState<FlagFilter>('all');
|
||||
const [searchById, setSearchById] = useState<Record<number, RowSearchState>>({});
|
||||
const [imageVersionById, setImageVersionById] = useState<Record<number, number>>({});
|
||||
const [fixingId, setFixingId] = useState<number | null>(null);
|
||||
const [fixErrorById, setFixErrorById] = useState<Record<number, string>>({});
|
||||
const [flagSavingId, setFlagSavingId] = useState<number | null>(null);
|
||||
const [queuePending, setQueuePending] = useState(0);
|
||||
|
||||
const searchByIdRef = useRef(searchById);
|
||||
@@ -315,6 +351,8 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
gallery_file_exists: true,
|
||||
detail_file_exists: true,
|
||||
detail_on_demand: false,
|
||||
fixed: true,
|
||||
checked: true,
|
||||
}
|
||||
: r
|
||||
)
|
||||
@@ -331,32 +369,55 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
[fixingId, searchById]
|
||||
);
|
||||
|
||||
const updateRowFlags = useCallback(
|
||||
async (rowId: number, flags: { checked?: boolean; fixed?: boolean }) => {
|
||||
setFlagSavingId(rowId);
|
||||
try {
|
||||
const updated = await api.updatePaintingCheckupFlags(rowId, flags);
|
||||
setRows((list) =>
|
||||
list.map((r) =>
|
||||
r.id === rowId ? { ...r, checked: updated.checked, fixed: updated.fixed } : r
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not save checkup flags.');
|
||||
} finally {
|
||||
setFlagSavingId(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
row.title.toLowerCase().includes(q) ||
|
||||
row.artist_name.toLowerCase().includes(q) ||
|
||||
String(row.year ?? '').includes(q) ||
|
||||
(row.gallery_file ?? '').toLowerCase().includes(q) ||
|
||||
row.detail_file.toLowerCase().includes(q)
|
||||
);
|
||||
}, [rows, filter]);
|
||||
|
||||
const filteredIds = useMemo(() => filtered.map((row) => row.id), [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
for (const rowId of filteredIds) {
|
||||
enqueueSearch(rowId);
|
||||
return rows.filter((row) => {
|
||||
if (q) {
|
||||
const matchesText =
|
||||
row.title.toLowerCase().includes(q) ||
|
||||
row.artist_name.toLowerCase().includes(q) ||
|
||||
String(row.year ?? '').includes(q) ||
|
||||
(row.gallery_file ?? '').toLowerCase().includes(q) ||
|
||||
row.detail_file.toLowerCase().includes(q);
|
||||
if (!matchesText) return false;
|
||||
}
|
||||
}, FILTER_SEARCH_DEBOUNCE_MS);
|
||||
if (filterChecked === 'yes' && !row.checked) return false;
|
||||
if (filterChecked === 'no' && row.checked) return false;
|
||||
if (filterFixed === 'yes' && !row.fixed) return false;
|
||||
if (filterFixed === 'no' && row.fixed) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rows, filter, filterChecked, filterFixed]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [filteredIds, loading, enqueueSearch]);
|
||||
const handleSearchVisible = useCallback(() => {
|
||||
for (const row of filtered) {
|
||||
enqueueSearch(row.id);
|
||||
}
|
||||
}, [filtered, enqueueSearch]);
|
||||
|
||||
const visibleNeedSearch = useMemo(
|
||||
() => filtered.filter((row) => needsSearch(searchById[row.id])).length,
|
||||
[filtered, searchById]
|
||||
);
|
||||
|
||||
const searchStats = useMemo(() => {
|
||||
let done = 0;
|
||||
@@ -374,7 +435,9 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
const onDemand = rows.filter((r) => r.detail_on_demand).length;
|
||||
const missingGallery = rows.filter((r) => r.gallery_file && !r.gallery_file_exists).length;
|
||||
const missingDetail = rows.filter((r) => r.detail_file_exists === false).length;
|
||||
return { noGallery, onDemand, missingGallery, missingDetail };
|
||||
const checked = rows.filter((r) => r.checked).length;
|
||||
const fixed = rows.filter((r) => r.fixed).length;
|
||||
return { noGallery, onDemand, missingGallery, missingDetail, checked, fixed };
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
@@ -386,7 +449,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
<div className="checkup-title-block">
|
||||
<h1>Painting checkup</h1>
|
||||
<p>
|
||||
Compare gallery vs detail images; filtered rows are searched automatically.
|
||||
Compare gallery vs detail images, then use Search visible to fetch reference images.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -399,8 +462,43 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-search-visible-btn"
|
||||
onClick={handleSearchVisible}
|
||||
disabled={loading || filtered.length === 0 || visibleNeedSearch === 0}
|
||||
title="Run Google-family image search for all rows currently shown in the table"
|
||||
>
|
||||
Search visible{visibleNeedSearch > 0 ? ` (${visibleNeedSearch})` : ''}
|
||||
</button>
|
||||
<label className="checkup-filter-select-wrap">
|
||||
<span>Checked</span>
|
||||
<select
|
||||
className="checkup-filter-select"
|
||||
value={filterChecked}
|
||||
onChange={(e) => setFilterChecked(e.target.value as FlagFilter)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="yes">Checked</option>
|
||||
<option value="no">Not checked</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkup-filter-select-wrap">
|
||||
<span>Fixed</span>
|
||||
<select
|
||||
className="checkup-filter-select"
|
||||
value={filterFixed}
|
||||
onChange={(e) => setFilterFixed(e.target.value as FlagFilter)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="yes">Fixed</option>
|
||||
<option value="no">Not fixed</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="checkup-stats">
|
||||
<span>{filtered.length} shown</span>
|
||||
<span>{stats.checked} checked</span>
|
||||
<span>{stats.fixed} fixed</span>
|
||||
<span>
|
||||
search {searchStats.done}/{filtered.length} done
|
||||
{queuePending > 0 ? ` · ${queuePending} active` : ''}
|
||||
@@ -429,13 +527,25 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
<th>Detail</th>
|
||||
<th>Search</th>
|
||||
<th>Fix</th>
|
||||
<th>Checked</th>
|
||||
<th>Fixed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((row) => {
|
||||
const searchState = searchById[row.id] ?? { status: 'idle' as const };
|
||||
const searchState = searchById[row.id];
|
||||
const flagsBusy = flagSavingId === row.id;
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
row.checked && row.fixed
|
||||
? 'checkup-row-done'
|
||||
: row.checked
|
||||
? 'checkup-row-checked'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td>
|
||||
{onOpenPainting ? (
|
||||
<button
|
||||
@@ -479,6 +589,22 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||
onFix={() => handleFix(row)}
|
||||
/>
|
||||
</td>
|
||||
<td className="checkup-flag-cell">
|
||||
<FlagCheckbox
|
||||
label="Reviewed"
|
||||
checked={row.checked}
|
||||
disabled={flagsBusy || row.fixed}
|
||||
onChange={(checked) => updateRowFlags(row.id, { checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="checkup-flag-cell">
|
||||
<FlagCheckbox
|
||||
label="Fixed"
|
||||
checked={row.fixed}
|
||||
disabled={flagsBusy}
|
||||
onChange={(fixed) => updateRowFlags(row.id, { fixed })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user