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>
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 211 KiB |
@@ -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) {
|
||||
const result = await pool.query(
|
||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||
@@ -436,6 +460,7 @@ module.exports = {
|
||||
replacePaintingImageFromUrl,
|
||||
replaceArtistPortraitFromUrl,
|
||||
clearPaintingImage,
|
||||
deletePainting,
|
||||
clearArtistPortrait,
|
||||
replacePaintingImageFromBuffer,
|
||||
replaceArtistPortraitFromBuffer,
|
||||
|
||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
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 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) => {
|
||||
try {
|
||||
const paintingId = parseInt(req.params.id, 10);
|
||||
|
||||