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>
This commit is contained in:
Danila Khodjaef
2026-06-22 22:32:23 +03:00
co-authored by Cursor
parent b2cae284ac
commit f542c689c9
31 changed files with 396 additions and 30 deletions
+9
View File
@@ -215,6 +215,15 @@ export const api = {
return res.json() as Promise<FixPaintingImageResult>;
}),
deletePainting: (id: number) =>
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Remove failed: ${res.status}`);
}
return res.json() as Promise<{ id: number; artistId: number; title: string }>;
}),
uploadPaintingImage: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
+10 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { Artist } from '../types';
import {
api,
@@ -15,6 +15,7 @@ import './ArtistBio.css';
interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
debugShowMore?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
@@ -31,6 +32,7 @@ interface Props {
export default function ArtistBio({
artist,
debugMode = false,
debugShowMore = false,
portraitRevision = 0,
onBack,
onEnterGallery,
@@ -127,7 +129,7 @@ export default function ArtistBio({
}
};
const handleOpenMore = async () => {
const handleOpenMore = useCallback(async () => {
setMoreOpen(true);
setMoreLoading(true);
setMoreError(null);
@@ -140,7 +142,12 @@ export default function ArtistBio({
} 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;
+25
View File
@@ -540,6 +540,10 @@
margin-top: 0;
}
.debug-action-buttons-danger {
margin-top: 4px;
}
.debug-clear-btn,
.debug-upload-btn {
flex: 1;
@@ -578,6 +582,27 @@
cursor: wait;
}
.debug-remove-btn {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(200, 60, 60, 0.65);
background: rgba(200, 60, 60, 0.12);
color: #e05050;
}
.debug-remove-btn:hover:not(:disabled) {
background: rgba(200, 60, 60, 0.22);
}
.debug-remove-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.painting-frame-empty {
min-height: 280px;
cursor: default;
+42 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
@@ -15,6 +15,7 @@ interface Props {
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
debugMode?: boolean;
debugShowMore?: boolean;
onPaintingImageFixed?: (
paintingId: number,
fixResult: FixPaintingImageResult
@@ -23,6 +24,7 @@ interface Props {
paintingId: number,
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
}
function influenceKey(inf: InfluenceLink, index: number): string {
@@ -188,8 +190,10 @@ export default function PaintingDetailView({
onArtistBio,
onInfluenceArtistClick,
debugMode = false,
debugShowMore = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
}: Props) {
const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false);
@@ -206,6 +210,7 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
@@ -230,6 +235,13 @@ export default function PaintingDetailView({
setMoreResults(null);
setMoreError(null);
setActiveAnnotationId(null);
setRemoving(false);
setFixing(false);
setClearing(false);
setUploading(false);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
}, [painting.id]);
useEffect(() => {
@@ -298,7 +310,7 @@ export default function PaintingDetailView({
}
};
const handleOpenMore = async () => {
const handleOpenMore = useCallback(async () => {
setMoreOpen(true);
setMoreLoading(true);
setMoreError(null);
@@ -311,7 +323,12 @@ export default function PaintingDetailView({
} finally {
setMoreLoading(false);
}
};
}, [painting.id]);
useEffect(() => {
if (!debugMode || !debugShowMore) return;
void handleOpenMore();
}, [debugMode, debugShowMore, painting.id, handleOpenMore]);
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
if (fixing || applyingUrl) return;
@@ -381,6 +398,18 @@ export default function PaintingDetailView({
}
};
const handleRemoveEntry = async () => {
if (removing || fixing || clearing || uploading || !onPaintingRemoved) return;
setRemoving(true);
setDebugError(null);
try {
await onPaintingRemoved(painting.id, painting.artist_id);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not remove painting.');
setRemoving(false);
}
};
useEffect(() => {
if (fullscreen) return;
@@ -614,6 +643,16 @@ export default function PaintingDetailView({
onChange={handleUploadFile}
/>
</div>
<div className="debug-action-buttons debug-action-buttons-danger">
<button
type="button"
className="debug-remove-btn"
onClick={handleRemoveEntry}
disabled={removing || fixing || clearing || uploading}
>
{removing ? '…' : 'Remove entry'}
</button>
</div>
</aside>
)}
+36
View File
@@ -80,6 +80,42 @@
color: #e8a040;
}
.debug-show-more-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: rgba(201, 169, 110, 0.75);
font-size: 12px;
font-family: ui-monospace, 'Cascadia Code', monospace;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
user-select: none;
}
.debug-show-more-toggle input {
accent-color: #e8a040;
cursor: pointer;
}
.debug-show-more-toggle:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.debug-show-more-toggle-active {
border-color: #e8a040;
background: rgba(232, 160, 64, 0.1);
color: #e8a040;
}
.debug-show-more-toggle-muted {
opacity: 0.55;
}
.checkup-link-btn {
text-decoration: none;
}
+157 -1
View File
@@ -9,7 +9,7 @@ import CheckupPage from '../pages/CheckupPage';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
import './HomePage.css';
type View =
@@ -53,6 +53,47 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
};
}
function patchReturnToAfterRemove(
returnTo: View,
freshArtist?: ArtistDetail,
freshMovement?: MovementGalleryDetail
): View {
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
return { ...returnTo, data: freshArtist };
}
if (
returnTo.type === 'movement-gallery' &&
freshMovement &&
returnTo.movementId === freshMovement.movement.id
) {
return { ...returnTo, data: freshMovement };
}
if (returnTo.type === 'painting') {
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
}
if (returnTo.type === 'bio') {
const data =
freshArtist && returnTo.artistId === freshArtist.artist.id ? freshArtist : returnTo.data;
return {
...returnTo,
data,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
};
}
return returnTo;
}
function catalogNavigateTarget(
sorted: Painting[],
removedId: number
): number | null {
const idx = sorted.findIndex((p) => p.id === removedId);
if (idx < 0) return null;
const remaining = sorted.filter((p) => p.id !== removedId);
if (remaining.length === 0) return null;
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
}
export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
@@ -67,6 +108,10 @@ export default function HomePage() {
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
const [hoveredLifespan, setHoveredLifespan] = useState<{
birthYear: number;
deathYear: number;
@@ -130,6 +175,11 @@ export default function HomePage() {
});
};
const setDebugShowMoreEnabled = (enabled: boolean) => {
setDebugShowMore(enabled);
writeDebugShowMore(enabled);
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -344,6 +394,95 @@ export default function HomePage() {
}
}, []);
const handlePaintingRemoved = useCallback(
async (paintingId: number, artistId: number) => {
const currentView = viewRef.current;
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
const nextId = catalogNavigateTarget(sorted, paintingId);
const inMovementCatalog =
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
await api.deletePainting(paintingId);
const freshArtist = await api.getArtist(artistId);
let freshMovement: MovementGalleryDetail | undefined;
if (gallerySession?.kind === 'movement') {
freshMovement = await api.getMovementGallery(gallerySession.movementId);
} else if (currentView.returnTo.type === 'movement-gallery') {
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
}
const freshCatalog =
inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
const navigateId =
nextId && freshCatalog.some((p) => p.id === nextId)
? nextId
: freshCatalog.length > 0
? freshCatalog[Math.min(removedIdx, freshCatalog.length - 1)]?.id ??
freshCatalog[0].id
: null;
setDetailArtistPaintings(freshCatalog);
setGallerySession((session) => {
if (!session) return session;
if (session.kind === 'artist' && session.artistId === artistId) {
return { ...session, data: freshArtist };
}
if (session.kind === 'movement' && freshMovement) {
return { ...session, data: freshMovement };
}
return session;
});
setImageRevisions((prev) => {
const next = { ...prev };
delete next[paintingId];
return next;
});
setGalleryRevision((v) => v + 1);
const patchedReturnTo = patchReturnToAfterRemove(
currentView.returnTo,
freshArtist,
freshMovement
);
detailReturnToRef.current = patchedReturnTo;
setView((current) => {
if (current.type === 'gallery' && current.artistId === artistId) {
return { ...current, data: freshArtist };
}
if (current.type === 'movement-gallery' && freshMovement) {
return { ...current, data: freshMovement };
}
if (current.type !== 'painting' || current.paintingId !== paintingId) {
return current;
}
if (navigateId) return current;
return patchedReturnTo;
});
if (navigateId) {
const data = await api.getPainting(navigateId);
setView({
type: 'painting',
paintingId: navigateId,
data,
returnTo: patchedReturnTo,
});
}
},
[detailArtistPaintings, gallerySession]
);
const handleBioClick = (artistData: ArtistDetail, returnTo: View) => {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
};
@@ -393,6 +532,7 @@ export default function HomePage() {
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
{gallerySession.kind === 'artist' ? (
<VirtualGallery
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
mode="artist"
data={gallerySession.data}
imageRevisions={imageRevisions}
@@ -410,6 +550,7 @@ export default function HomePage() {
/>
) : (
<VirtualGallery
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
mode="movement"
data={gallerySession.data}
imageRevisions={imageRevisions}
@@ -424,6 +565,7 @@ export default function HomePage() {
{view.type === 'painting' && (
<div className="home-overlay">
<PaintingDetailView
key={view.paintingId}
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
onBack={() => {
@@ -460,8 +602,10 @@ export default function HomePage() {
}}
onInfluenceArtistClick={handleArtistClick}
debugMode={debugMode}
debugShowMore={debugShowMore}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
/>
</div>
)}
@@ -471,6 +615,7 @@ export default function HomePage() {
<ArtistBio
artist={view.data.artist}
debugMode={debugMode}
debugShowMore={debugShowMore}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
@@ -501,6 +646,17 @@ export default function HomePage() {
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
+17
View File
@@ -1,4 +1,5 @@
const DEBUG_MODE_KEY = 'gallery-debug-mode';
const DEBUG_SHOW_MORE_KEY = 'gallery-debug-show-more';
export function readDebugMode(): boolean {
try {
@@ -15,3 +16,19 @@ export function writeDebugMode(enabled: boolean): void {
// ignore
}
}
export function readDebugShowMore(): boolean {
try {
return localStorage.getItem(DEBUG_SHOW_MORE_KEY) === '1';
} catch {
return false;
}
}
export function writeDebugShowMore(enabled: boolean): void {
try {
localStorage.setItem(DEBUG_SHOW_MORE_KEY, enabled ? '1' : '0');
} catch {
// ignore
}
}