From f542c689c93b01e44a55d5b8ede0e28a88b9b300 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 22 Jun 2026 22:32:23 +0300 Subject: [PATCH] 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 --- Documentation/API.md | 21 +++ Documentation/DB_structure.md | 4 +- Documentation/basics.md | 37 ++-- Documentation/data-and-images.md | 10 +- Documentation/setup.md | 7 +- README.md | 2 +- client/src/api/client.ts | 9 + client/src/components/ArtistBio.tsx | 13 +- client/src/components/PaintingDetail.css | 25 +++ client/src/components/PaintingDetail.tsx | 45 ++++- client/src/pages/HomePage.css | 36 ++++ client/src/pages/HomePage.tsx | 158 +++++++++++++++++- client/src/utils/debugMode.ts | 17 ++ ...homas_receiving_the_Gift_of_the_Girdle.jpg | Bin 5133792 -> 6054706 bytes ...Domenico_Ghirlandaio_Madonna_and_Child.jpg | Bin 438326 -> 191817 bytes ...ndaio_Madonna_of_Mercy_and_Lamentation.jpg | Bin 0 -> 194649 bytes .../Domenico_Ghirlandaio_Marriage_of_Mary.jpg | Bin 1729311 -> 180288 bytes ...io_The_Virgin_Adoring_the_Christ_Child.jpg | Bin 2321957 -> 0 bytes ...aio_Three_saints_fresco_by_Ghirlandaio.jpg | Bin 0 -> 12787 bytes ...receiving_the_Gift_of_the_Girdle_thumb.jpg | Bin 30460 -> 37394 bytes ..._robe_of_Matthew_the_Evangelist__thumb.jpg | Bin 19821 -> 0 bytes ...co_Ghirlandaio_Madonna_and_Child_thumb.jpg | Bin 42732 -> 58150 bytes ...Madonna_of_Mercy_and_Lamentation_thumb.jpg | Bin 19821 -> 73720 bytes ...ico_Ghirlandaio_Marriage_of_Mary_thumb.jpg | Bin 22125 -> 40587 bytes ..._Virgin_Adoring_the_Christ_Child_thumb.jpg | Bin 50332 -> 0 bytes ...ree_saints_fresco_by_Ghirlandaio_thumb.jpg | Bin 19821 -> 13957 bytes .../Domenico_Ghirlandaio_Visitation_thumb.jpg | Bin 19821 -> 0 bytes data/images/portraits/Honor__Daumier.jpg | Bin 1746563 -> 118224 bytes .../images/portraits/Jean-Fran_ois_Millet.jpg | Bin 2108723 -> 216304 bytes server/image-service.js | 25 +++ server/index.js | 17 +- 31 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 data/images/paintings/Domenico_Ghirlandaio_Madonna_of_Mercy_and_Lamentation.jpg delete mode 100644 data/images/paintings/Domenico_Ghirlandaio_The_Virgin_Adoring_the_Christ_Child.jpg create mode 100644 data/images/paintings/Domenico_Ghirlandaio_Three_saints_fresco_by_Ghirlandaio.jpg delete mode 100644 data/images/paintings/thumbs/Domenico_Ghirlandaio_Drapery_study__possibly_study_of_the_robe_of_Matthew_the_Evangelist__thumb.jpg delete mode 100644 data/images/paintings/thumbs/Domenico_Ghirlandaio_The_Virgin_Adoring_the_Christ_Child_thumb.jpg delete mode 100644 data/images/paintings/thumbs/Domenico_Ghirlandaio_Visitation_thumb.jpg diff --git a/Documentation/API.md b/Documentation/API.md index 8eedb96..536ad44 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -542,6 +542,26 @@ Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — s --- +### `DELETE /api/paintings/:id` + +Permanently remove a painting (debug **Remove entry** on painting detail). Deletes full + thumbnail files from disk, then deletes the `paintings` row. Related rows in `painting_influence_sources`, `painting_annotations`, and legacy `painting_influences` are removed by `ON DELETE CASCADE`. + +**Response** + +```json +{ + "id": 42, + "artistId": 7, + "title": "Example Work" +} +``` + +**Errors:** `404` if the painting does not exist. + +The client refetches the artist (and movement gallery when applicable), remounts the 3D hall, and opens the next or previous work in the catalog — or returns to the gallery if it was the last work. + +--- + ### `GET /api/debug/image-proxy` Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser). @@ -576,6 +596,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`: | `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` | | `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` | | `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` | +| `api.deletePainting(id)` | `DELETE /api/paintings/:id` | | `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` | | `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` | | `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 265782b..89cbf7f 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -1,6 +1,6 @@ # Art Gallery — database structure -PostgreSQL schema for the virtual gallery. Canonical DDL is intended to live in `db/schema.sql` when checked in; this document describes the logical model either way. +PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model. Connection settings come from `.env` (see [setup.md](setup.md)). @@ -189,7 +189,7 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id ## First-time setup -The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run admin grants as PostgreSQL superuser before `npm run migrate`: +The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run **`db/setup-admin.sql`** as PostgreSQL superuser (or the grants below) before `npm run migrate`: ```sql GRANT CREATE ON SCHEMA public TO gallery; diff --git a/Documentation/basics.md b/Documentation/basics.md index 39a93d4..414c50e 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -49,7 +49,7 @@ Gallery/ │ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor -│ │ ├── utils/debugMode.ts # Debug mode localStorage toggle +│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs │ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements │ └── dist/ # Production build (served by API when present) ├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools @@ -65,7 +65,8 @@ Gallery/ ├── Inputs/ # External datasets (e.g. PainterPalette.csv) ├── Output/ # Generated exports (e.g. paintings.csv) ├── data/images/ # Local portraits and paintings (+ thumbs/) -├── db/ # SQL schema and migrations (when present) +├── db/ # schema.sql, setup-admin.sql, migrate-*.sql +├── server/migrate.js # npm run migrate — schema + incremental migrations ├── deploy/ # Production nginx + systemd examples ├── Documentation/ # This folder └── .env # DB and port config (not committed) @@ -301,7 +302,9 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl ### Debug mode (developer) -When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and five action buttons. See [Developer tools (image audit)](#developer-tools-image-audit). +When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit). + +Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens the **More** search-results modal automatically whenever you open a painting or artist bio while debug mode is on. ## Key design decisions @@ -321,22 +324,28 @@ Optional workflow for curating local image files — not part of the public visi | Feature | Where | Purpose | |---------|--------|---------| | **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | +| **Show more** | Home header checkbox (same util) | When debug mode is on, auto-opens the **More** modal on each painting / bio page load | | **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | -| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + five action buttons | +| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | ### Debug panel (painting detail and artist bio) -When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **five buttons** in two rows: +When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and action buttons in two or three rows: -| Button | Action | -|--------|--------| -| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) | -| **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** | -| **More** | Opens a modal with up to **20** search results (each shows image resolution when available); click one to apply the same replace as **Fix it** | -| **Clear** | Deletes the local file(s), clears DB paths, leaves an **empty frame** (no placeholder); sets **Fixed** and **Reviewed** so on-demand fetch does not refill the slot | -| **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) | +| Button | Painting detail | Artist bio | +|--------|-----------------|------------| +| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags | +| **Fix it** | Replaces local image from top search result | Replaces portrait | +| **More** | Modal with up to **20** results (resolution shown when known) | Same | +| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot | +| **Upload** | Local file picker → disk + thumbnail | Local file → portrait | +| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — | -After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot. +After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately. + +Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot. + +Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible. ### Checkup page @@ -344,7 +353,7 @@ After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery tex **Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**. -Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode. +Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run start` (or `npm run dev:server`) so new routes (e.g. clear, upload, delete painting, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode. See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings). diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 41eddf6..6de4b9a 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -407,7 +407,7 @@ Runs `scripts/find-duplicate-paintings.js`, which reports: As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review. -**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`). +**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`). For confirmed duplicates, debug **Remove entry** on painting detail is faster than manual SQL; it deletes files and the row in one step. `expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently. @@ -415,15 +415,18 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab When **Debug mode** is on (home header) or from the **Checkup** page: +**Show more** (home header checkbox, `client/src/utils/debugMode.ts`) — when debug mode is on, automatically opens the **More** modal on each painting detail or artist bio page load (same as clicking **More**). + 1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`). 2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`. 3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`. 4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings). 5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`. +6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog. ### Debug panel (painting detail and artist bio) -With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and five buttons: +With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and action buttons: | Button | API (paintings / portraits) | Effect | |--------|----------------------------|--------| @@ -432,8 +435,9 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left | **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace | | **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI | | **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** | +| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation | -The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, or upload, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged. +The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged. Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load. diff --git a/Documentation/setup.md b/Documentation/setup.md index 2c67186..3a99222 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -54,10 +54,12 @@ npm run setup Or step by step: ```bash -npm run migrate # apply db/schema.sql +npm run migrate # db/schema.sql + db/migrate-*.sql via server/migrate.js npm run seed # eras, movements, artists, paintings, influences ``` +`npm run migrate` is safe to re-run on existing databases (uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`). + If migration fails with permission errors, grant schema rights to the app user first (see [DB_structure.md](DB_structure.md)). ### Recommended post-seed steps @@ -236,7 +238,8 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate | | Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff | | Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes | -| Debug **More** / **Clear** / **Upload** returns 404 | Same as above | Restart server; routes live in `server/index.js` + `server/image-service.js` | +| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` | +| Debug **Remove entry** — button stuck or missing on next painting | Stale client build | `cd client && npm run build`; hard-refresh — detail view remounts per painting id | | Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload | | No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` | | **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides | diff --git a/README.md b/README.md index bcce5ab..7930c44 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. +Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. ## Documentation diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 3bbae18..fe7ca54 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -215,6 +215,15 @@ export const api = { return res.json() as Promise; }), + 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(`${API}/paintings/${id}/upload-image`, payload); diff --git a/client/src/components/ArtistBio.tsx b/client/src/components/ArtistBio.tsx index be276b3..32d7d27 100644 --- a/client/src/components/ArtistBio.tsx +++ b/client/src/components/ArtistBio.tsx @@ -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; diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index 226e208..abd9434 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -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; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 37f69a0..808ddd0 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -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; + onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise; } 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(null); const [clearing, setClearing] = useState(false); const [uploading, setUploading] = useState(false); + const [removing, setRemoving] = useState(false); const [activeAnnotationId, setActiveAnnotationId] = useState(null); const uploadInputRef = useRef(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} /> +
+ +
)} diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css index a0ab980..c67887c 100644 --- a/client/src/pages/HomePage.css +++ b/client/src/pages/HomePage.css @@ -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; } diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 002fe29..ac07c8f 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -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) }; } +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({ type: 'timeline' }); const [gallerySession, setGallerySession] = useState(null); @@ -67,6 +108,10 @@ export default function HomePage() { const [imageRevisions, setImageRevisions] = useState>({}); const [portraitRevisions, setPortraitRevisions] = useState>({}); 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 = { @@ -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() {
{gallerySession.kind === 'artist' ? ( ) : ( { @@ -460,8 +602,10 @@ export default function HomePage() { }} onInfluenceArtistClick={handleArtistClick} debugMode={debugMode} + debugShowMore={debugShowMore} onPaintingImageFixed={handlePaintingImageFixed} onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated} + onPaintingRemoved={handlePaintingRemoved} />
)} @@ -471,6 +615,7 @@ export default function HomePage() { setView(view.returnTo)} onEnterGallery={() => @@ -501,6 +646,17 @@ export default function HomePage() { > Debug mode{debugMode ? ': ON' : ''} +