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
+21
View File
@@ -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` ### `GET /api/debug/image-proxy`
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser). 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.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` | | `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-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.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` | | `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` | | `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
+2 -2
View File
@@ -1,6 +1,6 @@
# Art Gallery — database structure # 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)). 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 ## 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 ```sql
GRANT CREATE ON SCHEMA public TO gallery; GRANT CREATE ON SCHEMA public TO gallery;
+23 -14
View File
@@ -49,7 +49,7 @@ Gallery/
│ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor │ │ ├── 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 │ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
│ └── dist/ # Production build (served by API when present) │ └── dist/ # Production build (served by API when present)
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools ├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
@@ -65,7 +65,8 @@ Gallery/
├── Inputs/ # External datasets (e.g. PainterPalette.csv) ├── Inputs/ # External datasets (e.g. PainterPalette.csv)
├── Output/ # Generated exports (e.g. paintings.csv) ├── Output/ # Generated exports (e.g. paintings.csv)
├── data/images/ # Local portraits and paintings (+ thumbs/) ├── 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 ├── deploy/ # Production nginx + systemd examples
├── Documentation/ # This folder ├── Documentation/ # This folder
└── .env # DB and port config (not committed) └── .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) ### 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 ## Key design decisions
@@ -321,22 +324,28 @@ Optional workflow for curating local image files — not part of the public visi
| Feature | Where | Purpose | | 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 | | **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 | | **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) ### 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 | | Button | Painting detail | Artist bio |
|--------|--------| |--------|-----------------|------------|
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) | | **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags |
| **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** | | **Fix it** | Replaces local image from top search result | Replaces portrait |
| **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** | | **More** | Modal with up to **20** results (resolution shown when known) | Same |
| **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 | | **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
| **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) | | **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 ### 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**. **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). See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings).
+7 -3
View File
@@ -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. 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. `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: 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`). 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`. 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`. 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). 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/`. 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) ### 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 | | 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 | | **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 | | **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** | | **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. Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
+5 -2
View File
@@ -54,10 +54,12 @@ npm run setup
Or step by step: Or step by step:
```bash ```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 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)). 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 ### 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 | | 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 | | 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 | | 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 | | 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` | | 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 | | **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides |
+1 -1
View File
@@ -1,6 +1,6 @@
# Art Gallery # 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 ## Documentation
+9
View File
@@ -215,6 +215,15 @@ export const api = {
return res.json() as Promise<FixPaintingImageResult>; 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) => { uploadPaintingImage: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file); const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload); 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 type { Artist } from '../types';
import { import {
api, api,
@@ -15,6 +15,7 @@ import './ArtistBio.css';
interface Props { interface Props {
artist: Artist & { movement_name?: string }; artist: Artist & { movement_name?: string };
debugMode?: boolean; debugMode?: boolean;
debugShowMore?: boolean;
portraitRevision?: number; portraitRevision?: number;
onBack: () => void; onBack: () => void;
onEnterGallery: () => void; onEnterGallery: () => void;
@@ -31,6 +32,7 @@ interface Props {
export default function ArtistBio({ export default function ArtistBio({
artist, artist,
debugMode = false, debugMode = false,
debugShowMore = false,
portraitRevision = 0, portraitRevision = 0,
onBack, onBack,
onEnterGallery, onEnterGallery,
@@ -127,7 +129,7 @@ export default function ArtistBio({
} }
}; };
const handleOpenMore = async () => { const handleOpenMore = useCallback(async () => {
setMoreOpen(true); setMoreOpen(true);
setMoreLoading(true); setMoreLoading(true);
setMoreError(null); setMoreError(null);
@@ -140,7 +142,12 @@ export default function ArtistBio({
} finally { } finally {
setMoreLoading(false); setMoreLoading(false);
} }
}; }, [artist.id]);
useEffect(() => {
if (!debugMode || !debugShowMore) return;
void handleOpenMore();
}, [debugMode, debugShowMore, artist.id, handleOpenMore]);
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => { const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
if (fixing || applyingUrl) return; if (fixing || applyingUrl) return;
+25
View File
@@ -540,6 +540,10 @@
margin-top: 0; margin-top: 0;
} }
.debug-action-buttons-danger {
margin-top: 4px;
}
.debug-clear-btn, .debug-clear-btn,
.debug-upload-btn { .debug-upload-btn {
flex: 1; flex: 1;
@@ -578,6 +582,27 @@
cursor: wait; 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 { .painting-frame-empty {
min-height: 280px; min-height: 280px;
cursor: default; 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 type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client'; import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal'; import DebugSearchResultsModal from './DebugSearchResultsModal';
@@ -15,6 +15,7 @@ interface Props {
onArtistBio: () => void; onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void; onInfluenceArtistClick?: (artistId: number) => void;
debugMode?: boolean; debugMode?: boolean;
debugShowMore?: boolean;
onPaintingImageFixed?: ( onPaintingImageFixed?: (
paintingId: number, paintingId: number,
fixResult: FixPaintingImageResult fixResult: FixPaintingImageResult
@@ -23,6 +24,7 @@ interface Props {
paintingId: number, paintingId: number,
flags: { checked: boolean; fixed: boolean } flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>; ) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
} }
function influenceKey(inf: InfluenceLink, index: number): string { function influenceKey(inf: InfluenceLink, index: number): string {
@@ -188,8 +190,10 @@ export default function PaintingDetailView({
onArtistBio, onArtistBio,
onInfluenceArtistClick, onInfluenceArtistClick,
debugMode = false, debugMode = false,
debugShowMore = false,
onPaintingImageFixed, onPaintingImageFixed,
onPaintingCheckupFlagsUpdated, onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
}: Props) { }: Props) {
const { painting, influencedBy, influenced, annotations = [] } = data; const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
@@ -206,6 +210,7 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null); const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null); const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null); const uploadInputRef = useRef<HTMLInputElement>(null);
@@ -230,6 +235,13 @@ export default function PaintingDetailView({
setMoreResults(null); setMoreResults(null);
setMoreError(null); setMoreError(null);
setActiveAnnotationId(null); setActiveAnnotationId(null);
setRemoving(false);
setFixing(false);
setClearing(false);
setUploading(false);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
}, [painting.id]); }, [painting.id]);
useEffect(() => { useEffect(() => {
@@ -298,7 +310,7 @@ export default function PaintingDetailView({
} }
}; };
const handleOpenMore = async () => { const handleOpenMore = useCallback(async () => {
setMoreOpen(true); setMoreOpen(true);
setMoreLoading(true); setMoreLoading(true);
setMoreError(null); setMoreError(null);
@@ -311,7 +323,12 @@ export default function PaintingDetailView({
} finally { } finally {
setMoreLoading(false); setMoreLoading(false);
} }
}; }, [painting.id]);
useEffect(() => {
if (!debugMode || !debugShowMore) return;
void handleOpenMore();
}, [debugMode, debugShowMore, painting.id, handleOpenMore]);
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => { const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
if (fixing || applyingUrl) return; 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(() => { useEffect(() => {
if (fullscreen) return; if (fullscreen) return;
@@ -614,6 +643,16 @@ export default function PaintingDetailView({
onChange={handleUploadFile} onChange={handleUploadFile}
/> />
</div> </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> </aside>
)} )}
+36
View File
@@ -80,6 +80,42 @@
color: #e8a040; 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 { .checkup-link-btn {
text-decoration: none; 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 { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types'; import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, writeDebugMode } from '../utils/debugMode'; import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
import './HomePage.css'; import './HomePage.css';
type View = 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() { export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' }); const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null); const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
@@ -67,6 +108,10 @@ export default function HomePage() {
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({}); const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({}); const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode); 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<{ const [hoveredLifespan, setHoveredLifespan] = useState<{
birthYear: number; birthYear: number;
deathYear: 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 handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId); const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = { 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) => { const handleBioClick = (artistData: ArtistDetail, returnTo: View) => {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo }); 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}> <div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
{gallerySession.kind === 'artist' ? ( {gallerySession.kind === 'artist' ? (
<VirtualGallery <VirtualGallery
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
mode="artist" mode="artist"
data={gallerySession.data} data={gallerySession.data}
imageRevisions={imageRevisions} imageRevisions={imageRevisions}
@@ -410,6 +550,7 @@ export default function HomePage() {
/> />
) : ( ) : (
<VirtualGallery <VirtualGallery
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
mode="movement" mode="movement"
data={gallerySession.data} data={gallerySession.data}
imageRevisions={imageRevisions} imageRevisions={imageRevisions}
@@ -424,6 +565,7 @@ export default function HomePage() {
{view.type === 'painting' && ( {view.type === 'painting' && (
<div className="home-overlay"> <div className="home-overlay">
<PaintingDetailView <PaintingDetailView
key={view.paintingId}
data={view.data} data={view.data}
artistPaintings={sortedDetailArtistPaintings} artistPaintings={sortedDetailArtistPaintings}
onBack={() => { onBack={() => {
@@ -460,8 +602,10 @@ export default function HomePage() {
}} }}
onInfluenceArtistClick={handleArtistClick} onInfluenceArtistClick={handleArtistClick}
debugMode={debugMode} debugMode={debugMode}
debugShowMore={debugShowMore}
onPaintingImageFixed={handlePaintingImageFixed} onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated} onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
/> />
</div> </div>
)} )}
@@ -471,6 +615,7 @@ export default function HomePage() {
<ArtistBio <ArtistBio
artist={view.data.artist} artist={view.data.artist}
debugMode={debugMode} debugMode={debugMode}
debugShowMore={debugShowMore}
portraitRevision={portraitRevisions[view.data.artist.id]} portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)} onBack={() => setView(view.returnTo)}
onEnterGallery={() => onEnterGallery={() =>
@@ -501,6 +646,17 @@ export default function HomePage() {
> >
Debug mode{debugMode ? ': ON' : ''} Debug mode{debugMode ? ': ON' : ''}
</button> </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 <button
type="button" type="button"
className="checkup-link-btn" className="checkup-link-btn"
+17
View File
@@ -1,4 +1,5 @@
const DEBUG_MODE_KEY = 'gallery-debug-mode'; const DEBUG_MODE_KEY = 'gallery-debug-mode';
const DEBUG_SHOW_MORE_KEY = 'gallery-debug-show-more';
export function readDebugMode(): boolean { export function readDebugMode(): boolean {
try { try {
@@ -15,3 +16,19 @@ export function writeDebugMode(enabled: boolean): void {
// ignore // 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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 211 KiB

+25
View File
@@ -189,6 +189,30 @@ function unlinkPortraitFiles(row, safeBase) {
} }
} }
async function deletePainting(paintingId) {
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
throw new Error('Painting not found');
}
const row = result.rows[0];
const safeBase = safePaintingBase(row.artist_name, row.title);
unlinkPaintingFiles(row, safeBase);
inflight.delete(`${paintingId}:thumb`);
inflight.delete(`${paintingId}:full`);
await pool.query(`DELETE FROM paintings WHERE id = $1`, [paintingId]);
return { id: row.id, artistId: row.artist_id, title: row.title };
}
async function clearPaintingImage(paintingId) { async function clearPaintingImage(paintingId) {
const result = await pool.query( const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
@@ -436,6 +460,7 @@ module.exports = {
replacePaintingImageFromUrl, replacePaintingImageFromUrl,
replaceArtistPortraitFromUrl, replaceArtistPortraitFromUrl,
clearPaintingImage, clearPaintingImage,
deletePainting,
clearArtistPortrait, clearArtistPortrait,
replacePaintingImageFromBuffer, replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer, replaceArtistPortraitFromBuffer,
+16 -1
View File
@@ -5,7 +5,7 @@ const fs = require('fs');
require('dotenv').config(); require('dotenv').config();
const pool = require('./db'); const pool = require('./db');
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service');
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
const app = express(); const app = express();
@@ -733,6 +733,21 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
} }
}); });
app.delete('/api/paintings/:id', async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
return res.status(400).json({ error: 'Invalid painting id' });
}
const removed = await deletePainting(paintingId);
res.json(removed);
} catch (err) {
console.error('Delete painting error:', err.message);
const status = err.message === 'Painting not found' ? 404 : 500;
res.status(status).json({ error: err.message || 'Could not remove painting' });
}
});
app.post('/api/paintings/:id/clear-image', async (req, res) => { app.post('/api/paintings/:id/clear-image', async (req, res) => {
try { try {
const paintingId = parseInt(req.params.id, 10); const paintingId = parseInt(req.params.id, 10);