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:
co-authored by
Cursor
parent
b2cae284ac
commit
f542c689c9
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user