Fix debug upload persistence and UX; exclude prod audit log from DB restore

Uploads and fixes now bust browser cache via file-mtime keys in API
responses. Debug upload shows a centered loading overlay and blocks search
while uploading. Prod DB restore skips curator_audit_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-09 18:30:19 +03:00
co-authored by Cursor
parent 62096e8210
commit f78c14f307
18 changed files with 506 additions and 154 deletions
+24 -7
View File
@@ -17,6 +17,8 @@ All JSON responses use `Content-Type: application/json`. Errors return `{ "error
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`. Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
**Caching:** `/images` responses use `Cache-Control: public, max-age=0, must-revalidate` with `ETag` / `Last-Modified`. Painting and artist JSON payloads include optional **`image_cache_key`** / **`thumbnail_cache_key`** (and **`portrait_cache_key`** / **`portrait_thumb_cache_key`** on artists) — Unix ms from the files `mtime` on disk. The client appends `?v=<key>` to image URLs so fix/upload/clear updates show immediately after reload even when the relative path is unchanged.
**Quick check:** **Quick check:**
```powershell ```powershell
@@ -440,7 +442,18 @@ Painting detail with influence graph neighbours.
```json ```json
{ {
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... }, "painting": {
"id": 10,
"title": "...",
"artist_name": "...",
"image_path": "paintings/Artist_Title.jpg",
"thumbnail_path": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"checkup_checked": false,
"checkup_fixed": false,
"has_influence_links": true
},
"influencedBy": [ "influencedBy": [
{ {
"source_type": "painting", "source_type": "painting",
@@ -600,6 +613,8 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
{ {
"imagePath": "paintings/Artist_Title.jpg", "imagePath": "paintings/Artist_Title.jpg",
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg", "thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"fixed": true, "fixed": true,
"checked": true "checked": true
} }
@@ -660,7 +675,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes
} }
``` ```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`). Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `image_cache_key`, `thumbnail_cache_key`, `fixed`, `checked`).
--- ---
@@ -711,11 +726,13 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` | | `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
| `api.getPainting(id)` | `GET /api/paintings/:id` | | `api.getPainting(id)` | `GET /api/paintings/:id` |
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` | | `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
| `imageUrl(path)` | `/images/<path>` or placeholder | | `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) | | `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix | | `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) | | `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster | | `paintingImageUrl(painting, sessionRevision?)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?, artist?)` | `/images/<path>` with `?v=` from revision or artist cache keys |
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` | | `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` | | `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` | | `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
+3 -1
View File
@@ -405,11 +405,13 @@ When debug mode is on, a panel at the bottom-left shows the image search query,
| **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) | | **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) |
| **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same | | **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same |
| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot | | **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
| **Upload** | Local file picker → full image + **auto-generated painting thumb** | Local file → portrait + **auto-generated portrait thumb** | | **Upload** | Local file picker (`DebugUploadButton`) → full image + **auto-generated painting thumb**; full-page **Loading…** overlay; hides current image and pauses search/fix until upload finishes | Local file → portrait + **auto-generated portrait thumb**; same upload overlay behaviour |
| **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. | — | | **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. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately. After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
Pressing **Upload** clears the debug search preview and closes **More** before the file picker opens. While uploading, **Fix it**, **More**, and **Checked** are disabled and the main painting/portrait is hidden behind a centered loading overlay.
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. 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. Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible.
+3 -3
View File
@@ -481,7 +481,7 @@ When **Debug mode** is on (home header) or from the **Checkup** page:
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`. The server **always regenerates thumbnails from the saved full image** (`writePaintingThumb` / `writePortraitThumb` via `sharp` — not the search-result thumb URL), updates `thumbnail_path` / `portrait_thumb_path`, 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`. The server **always regenerates thumbnails from the saved full image** (`writePaintingThumb` / `writePortraitThumb` via `sharp` — not the search-result thumb URL), updates `thumbnail_path` / `portrait_thumb_path`, 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/`, and regenerates the matching thumbnail the same way as **Fix it**. 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/`, and regenerates the matching thumbnail the same way as **Fix it**. The file picker uses a native `<label>` + hidden `<input>` (`DebugUploadButton.tsx`) so the `change` event is reliable on Windows/Chromium.
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. 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)
@@ -494,10 +494,10 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline | | **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline |
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) | | **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) |
| **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 full image + **auto-generated thumb** | | **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker (`DebugUploadButton`) → save full image + **auto-generated thumb**; centered **Loading…** overlay; clears preview and blocks search/fix while uploading |
| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation | | **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, 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. 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. Image URLs include `?v=<mtime_ms>` from API **`image_cache_key`** / **`thumbnail_cache_key`** (file `mtime` on disk) so replaced files reload after a full page refresh even when the relative path is unchanged. In-session counters still bump immediately after a mutation.
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.
+1 -1
View File
@@ -242,7 +242,7 @@ Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole
**Caveats — prod tables are replaced by dev's contents:** **Caveats — prod tables are replaced by dev's contents:**
- `users` and `curator_audit_log` are overwritten. The **dev curator account and password become the prod login**, and prod audit history is replaced. Make sure the dev curator credentials are the ones you want in prod. - `users` is overwritten. The **dev curator account and password become the prod login**. `curator_audit_log` is **not** synced — prod keeps its existing audit history.
- The `session` table is truncated, so any active prod curator sessions are logged out. - The `session` table is truncated, so any active prod curator sessions are logged out.
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`. - The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
+3 -1
View File
@@ -258,9 +258,11 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run dev:start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` | | Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run dev: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 **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 |
| Debug **Upload** — nothing happens after choosing file | Stale client or broken hidden-file `click()` | Pull latest client (`DebugUploadButton` uses `<label>` + `<input>`); hard-refresh |
| Uploaded image reverts after reload (old picture) | Browser cached `/images/…` at same path | Pull latest server + client — API returns `image_cache_key` and URLs use `?v=`; `/images` no longer uses immutable long-term cache |
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run dev:migrate:painting-annotations` then `npm run dev:update-painting-annotations` | | No art-history notes on painting detail | Annotations not migrated or loaded | `npm run dev:migrate:painting-annotations` then `npm run dev: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 |
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) | | Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix/upload updates session + `?v=` from `image_cache_key` — use **Back to Gallery** (not browser back) |
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay | | Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually | | Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually |
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes | | **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
+102 -29
View File
@@ -52,36 +52,63 @@ export async function logoutCurator(): Promise<void> {
if (!res.ok) throw new Error(`Logout failed: ${res.status}`); if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
} }
export function imageUrl(path: string | null | undefined): string { export function imageUrl(path: string | null | undefined, revision?: number | null): string {
if (!path) return '/placeholder-art.svg'; const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
if (!revision || base.startsWith('/placeholder')) return base; if (!revision || base.startsWith('/placeholder')) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
} }
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */ export function portraitUrl(
path: string | null | undefined,
revision?: number | null,
options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null }
): string {
const cacheRevision =
revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined;
return imageUrl(path, cacheRevision);
}
export function portraitThumbUrl( export function portraitThumbUrl(
artist: { artist: {
portrait_thumb_path?: string | null; portrait_thumb_path?: string | null;
portrait_path?: string | null; portrait_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
}, },
revision?: number revision?: number | null
): string { ): string {
const path = artist.portrait_thumb_path || artist.portrait_path; const path = artist.portrait_thumb_path || artist.portrait_path;
return portraitUrl(path, revision); const cacheRevision =
revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined;
return portraitUrl(path, cacheRevision);
}
export function paintingImageRevision(
painting: {
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): number | undefined {
const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key;
if (apiRevision != null) return apiRevision;
if (sessionRevision != null) return sessionRevision;
return undefined;
} }
/** Image for 3D gallery — prefer thumbnail for faster texture loads */ /** Image for 3D gallery — prefer thumbnail for faster texture loads */
export function galleryImageUrl(painting: { export function galleryImageUrl(
thumbnail_path?: string | null; painting: {
image_path?: string | null; thumbnail_path?: string | null;
}): string | null { image_path?: string | null;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; image_cache_key?: number | null;
if (painting.image_path) return `/images/${painting.image_path}`; thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
return null; return null;
} }
@@ -90,27 +117,35 @@ export function galleryImageUrlWithRevision(
id?: number; id?: number;
thumbnail_path?: string | null; thumbnail_path?: string | null;
image_path?: string | null; image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
}, },
revision?: number sessionRevision?: number | null
): string | null { ): string | null {
const base = galleryImageUrl(painting); return galleryImageUrl(painting, sessionRevision);
if (!base || !revision) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
} }
export function paintingImageUrl(painting: { export function paintingImageUrl(
id: number; painting: {
image_path?: string | null; id: number;
thumbnail_path?: string | null; image_path?: string | null;
checkup_fixed?: boolean; thumbnail_path?: string | null;
}): string | null { checkup_fixed?: boolean;
if (painting.image_path) return `/images/${painting.image_path}`; image_cache_key?: number | null;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.checkup_fixed) return null; if (painting.checkup_fixed) return null;
return `/api/paintings/${painting.id}/image?size=full`; return `/api/paintings/${painting.id}/image?size=full`;
} }
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> { async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
validateDebugUploadFile(file);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@@ -122,7 +157,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
const comma = result.indexOf(','); const comma = result.indexOf(',');
resolve({ resolve({
imageData: comma >= 0 ? result.slice(comma + 1) : result, imageData: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || 'image/jpeg', mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
}); });
}; };
reader.onerror = () => reject(new Error('Could not read file')); reader.onerror = () => reject(new Error('Could not read file'));
@@ -130,6 +165,39 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
}); });
} }
function mimeTypeFromFilename(filename: string): string | null {
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
case 'gif':
return 'image/gif';
case 'avif':
return 'image/avif';
default:
return null;
}
}
export function validateDebugUploadFile(file: File): void {
const maxBytes = 15 * 1024 * 1024;
if (file.size <= 0) {
throw new Error('Selected file is empty.');
}
if (file.size > maxBytes) {
throw new Error('Image too large (max 15 MB).');
}
const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name);
if (!file.type.startsWith('image/') && !nameOk) {
throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).');
}
}
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> { async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, { const res = await fetch(url, {
...fetchCredentials, ...fetchCredentials,
@@ -156,12 +224,17 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
export interface FixPaintingImageResult { export interface FixPaintingImageResult {
imagePath: string | null; imagePath: string | null;
thumbnailPath: string | null; thumbnailPath: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
fixed?: boolean; fixed?: boolean;
checked?: boolean; checked?: boolean;
} }
export interface FixArtistPortraitResult { export interface FixArtistPortraitResult {
portraitPath: string | null; portraitPath: string | null;
portraitThumbPath?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
fixed?: boolean; fixed?: boolean;
checked?: boolean; checked?: boolean;
} }
+1
View File
@@ -1,4 +1,5 @@
.artist-bio { .artist-bio {
position: relative;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #e8d5b5; color: #e8d5b5;
+53 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { Artist } from '../types'; import type { Artist } from '../types';
import { import {
api, api,
@@ -9,7 +9,10 @@ import {
type FixArtistPortraitResult, type FixArtistPortraitResult,
} from '../api/client'; } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal'; import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import '../components/PaintingDetail.css'; import '../components/PaintingDetail.css';
import '../components/GalleryLoadingMarker.css';
import './ArtistBio.css'; import './ArtistBio.css';
interface Props { interface Props {
@@ -51,11 +54,13 @@ export default function ArtistBio({
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 uploadInputRef = useRef<HTMLInputElement>(null); const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed; const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed; const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined); const portraitSrc = uploading
? null
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
const lifespan = const lifespan =
artist.birth_year && artist.death_year artist.birth_year && artist.death_year
@@ -71,6 +76,9 @@ export default function ArtistBio({
setMoreOpen(false); setMoreOpen(false);
return; return;
} }
if (uploading) {
return;
}
let cancelled = false; let cancelled = false;
setDebugLoading(true); setDebugLoading(true);
@@ -91,7 +99,7 @@ export default function ArtistBio({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [debugMode, artist.id, artist.name]); }, [debugMode, uploading, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => { const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) { if (onArtistPortraitFixed) {
@@ -197,20 +205,27 @@ export default function ArtistBio({
} }
}; };
const handleUploadClick = () => { const handleUploadPress = () => {
uploadInputRef.current?.click(); setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
}; };
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => { const handleUploadFile = async (file: File) => {
const file = e.target.files?.[0]; if (uploading || fixing || clearing) return;
e.target.value = ''; handleUploadPress();
if (!file || uploading || fixing || clearing) return;
setUploading(true); setUploading(true);
setDebugError(null); setUploadStatus('Reading file…');
try { try {
setUploadStatus('Uploading…');
const result = await api.uploadArtistPortrait(artist.id, file); const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result); await applyPortraitUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) { } catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.'); setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally { } finally {
setUploading(false); setUploading(false);
@@ -219,6 +234,13 @@ export default function ArtistBio({
return ( return (
<div className="artist-bio"> <div className="artist-bio">
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="bio-header"> <header className="bio-header">
<button className="back-btn" onClick={onBack}> Back</button> <button className="back-btn" onClick={onBack}> Back</button>
<h1>{artist.name}</h1> <h1>{artist.name}</h1>
@@ -227,9 +249,9 @@ export default function ArtistBio({
<div className="bio-content"> <div className="bio-content">
<div <div
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`} className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
> >
{showPortrait ? ( {showPortrait && portraitSrc ? (
<img <img
src={portraitSrc} src={portraitSrc}
alt={artist.name} alt={artist.name}
@@ -273,14 +295,17 @@ export default function ArtistBio({
</div> </div>
{debugMode && ( {debugMode && (
<aside className="debug-image-panel" aria-label="Debug portrait search"> <aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug portrait search"
>
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4> <h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
<p className="debug-image-query"> <p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`} {debugSearch?.query ?? `${artist.name} portrait`}
</p> </p>
{debugLoading && <p className="debug-image-status">Searching</p>} {debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>} {debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && ( {!uploading && !debugLoading && debugSearch?.imageUrl && (
<img <img
className="debug-image-preview" className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, { src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -293,7 +318,7 @@ export default function ArtistBio({
}} }}
/> />
)} )}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && ( {!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No portrait image result found.</p> <p className="debug-image-status">No portrait image result found.</p>
)} )}
<div className="debug-action-buttons"> <div className="debug-action-buttons">
@@ -301,7 +326,7 @@ export default function ArtistBio({
type="button" type="button"
className="debug-checked-btn" className="debug-checked-btn"
onClick={handleMarkChecked} onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked} disabled={!!artist.checkup_checked || markingChecked || uploading}
> >
{markingChecked ? '…' : 'Checked'} {markingChecked ? '…' : 'Checked'}
</button> </button>
@@ -309,7 +334,7 @@ export default function ArtistBio({
type="button" type="button"
className="debug-fix-btn" className="debug-fix-btn"
onClick={handleFixPortrait} onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl} disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
> >
{fixing ? '…' : 'Fix it'} {fixing ? '…' : 'Fix it'}
</button> </button>
@@ -317,7 +342,7 @@ export default function ArtistBio({
type="button" type="button"
className="debug-more-btn" className="debug-more-btn"
onClick={handleOpenMore} onClick={handleOpenMore}
disabled={debugLoading || moreLoading} disabled={debugLoading || moreLoading || uploading}
> >
{moreLoading ? '…' : 'More'} {moreLoading ? '…' : 'More'}
</button> </button>
@@ -331,27 +356,18 @@ export default function ArtistBio({
> >
{clearing ? '…' : 'Clear'} {clearing ? '…' : 'Clear'}
</button> </button>
<button <DebugUploadButton
type="button" uploading={uploading}
className="debug-upload-btn" disabled={fixing || clearing}
onClick={handleUploadClick} onUploadPress={handleUploadPress}
disabled={uploading || fixing || clearing} onFileSelected={handleUploadFile}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
/> />
</div> </div>
</aside> </aside>
)} )}
<DebugSearchResultsModal <DebugSearchResultsModal
open={moreOpen} open={moreOpen && !uploading}
title="Choose portrait" title="Choose portrait"
data={moreResults} data={moreResults}
loading={moreLoading} loading={moreLoading}
@@ -0,0 +1,57 @@
import { useRef, type ChangeEvent } from 'react';
interface Props {
uploading: boolean;
disabled?: boolean;
onUploadPress?: () => void;
onFileSelected: (file: File) => void | Promise<void>;
}
export default function DebugUploadButton({
uploading,
disabled = false,
onUploadPress,
onFileSelected,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const inactive = disabled || uploading;
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const file = input.files?.[0];
if (!file || inactive) return;
try {
await onFileSelected(file);
} finally {
input.value = '';
}
};
return (
<label
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
aria-busy={uploading}
onClick={() => {
if (!inactive) onUploadPress?.();
}}
>
{uploading ? (
<span className="debug-upload-btn-loading">
<span className="debug-upload-btn-spinner" aria-hidden />
Uploading
</span>
) : (
'Upload'
)}
<input
ref={inputRef}
className="debug-upload-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
disabled={inactive}
onChange={handleChange}
/>
</label>
);
}
@@ -54,6 +54,22 @@
border-width: 2px; border-width: 2px;
} }
.gallery-loading-marker-compact {
flex-direction: row;
justify-content: flex-start;
gap: 8px;
margin: 6px 0 8px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
}
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
width: 14px;
height: 14px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes gallery-loading-spin { @keyframes gallery-loading-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -6,6 +6,8 @@ interface Props {
overlay?: boolean; overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */ /** Compact strip along the bottom — does not block interaction. */
banner?: boolean; banner?: boolean;
/** Inline row for small panels (debug upload, etc.). */
compact?: boolean;
className?: string; className?: string;
} }
@@ -13,13 +15,16 @@ export default function GalleryLoadingMarker({
message = 'Loading…', message = 'Loading…',
overlay = false, overlay = false,
banner = false, banner = false,
compact = false,
className = '', className = '',
}: Props) { }: Props) {
const modeClass = overlay const modeClass = overlay
? ' gallery-loading-marker-overlay' ? ' gallery-loading-marker-overlay'
: banner : banner
? ' gallery-loading-marker-banner' ? ' gallery-loading-marker-banner'
: ''; : compact
? ' gallery-loading-marker-compact'
: '';
return ( return (
<div <div
+43 -2
View File
@@ -552,6 +552,48 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
text-align: center;
}
.debug-upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.debug-upload-btn-disabled {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.debug-upload-btn-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.debug-upload-page-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 300;
}
.painting-frame-uploading,
.bio-portrait-uploading {
min-height: 240px;
}
.debug-image-panel.debug-image-panel-blocked {
opacity: 0.55;
pointer-events: none;
} }
.debug-clear-btn { .debug-clear-btn {
@@ -576,8 +618,7 @@
border-color: #8cbe8c; border-color: #8cbe8c;
} }
.debug-clear-btn:disabled, .debug-clear-btn:disabled {
.debug-upload-btn:disabled {
opacity: 0.6; opacity: 0.6;
cursor: wait; cursor: wait;
} }
+63 -50
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react'; import { useCallback, useEffect, useState, 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';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations'; import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox'; import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css'; import './PaintingDetail.css';
import './GalleryLoadingMarker.css';
interface Props { interface Props {
data: PaintingDetail; data: PaintingDetail;
@@ -214,15 +217,13 @@ 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 [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [removing, setRemoving] = 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 baseImageUrl = paintingImageUrl(painting); const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
const imageSrc = baseImageUrl const displayImageSrc = uploading ? null : imageSrc;
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}` const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id); const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null; const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -243,6 +244,7 @@ export default function PaintingDetailView({
setFixing(false); setFixing(false);
setClearing(false); setClearing(false);
setUploading(false); setUploading(false);
setUploadStatus(null);
setMarkingChecked(false); setMarkingChecked(false);
setApplyingUrl(null); setApplyingUrl(null);
setMoreLoading(false); setMoreLoading(false);
@@ -255,6 +257,9 @@ export default function PaintingDetailView({
setMoreOpen(false); setMoreOpen(false);
return; return;
} }
if (uploading) {
return;
}
let cancelled = false; let cancelled = false;
setDebugLoading(true); setDebugLoading(true);
@@ -275,7 +280,7 @@ export default function PaintingDetailView({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [debugMode, painting.id, painting.title, painting.artist_name]); }, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => { const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1); setImageVersion((v) => v + 1);
@@ -382,20 +387,27 @@ export default function PaintingDetailView({
} }
}; };
const handleUploadClick = () => { const handleUploadPress = () => {
uploadInputRef.current?.click(); setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
}; };
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => { const handleUploadFile = async (file: File) => {
const file = e.target.files?.[0]; if (uploading || fixing || clearing) return;
e.target.value = ''; handleUploadPress();
if (!file || uploading || fixing || clearing) return;
setUploading(true); setUploading(true);
setDebugError(null); setUploadStatus('Reading file…');
try { try {
setUploadStatus('Uploading…');
const result = await api.uploadPaintingImage(painting.id, file); const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result); await applyImageUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) { } catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload image.'); setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally { } finally {
setUploading(false); setUploading(false);
@@ -437,6 +449,13 @@ export default function PaintingDetailView({
return ( return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}> <div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="painting-header"> <header className="painting-header">
<button className="back-btn" onClick={onBack}> Back to Gallery</button> <button className="back-btn" onClick={onBack}> Back to Gallery</button>
<div className="painting-title-block"> <div className="painting-title-block">
@@ -494,12 +513,12 @@ export default function PaintingDetailView({
)} )}
<div <div
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`} className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
role={imageSrc ? 'button' : undefined} role={displayImageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined} tabIndex={displayImageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined} onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={ onKeyDown={
imageSrc displayImageSrc
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
@@ -508,14 +527,14 @@ export default function PaintingDetailView({
} }
: undefined : undefined
} }
title={imageSrc ? 'View full screen' : undefined} title={displayImageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`} aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
> >
<div className="painting-frame-image-wrap"> <div className="painting-frame-image-wrap">
{imageSrc ? ( {displayImageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} /> <img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null} ) : null}
{imageSrc && annotations.length > 0 && ( {displayImageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers <PaintingAnnotationMarkers
annotations={annotations} annotations={annotations}
activeId={activeAnnotationId} activeId={activeAnnotationId}
@@ -573,14 +592,17 @@ export default function PaintingDetailView({
</div> </div>
{debugMode && ( {debugMode && (
<aside className="debug-image-panel" aria-label="Debug image search"> <aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug image search"
>
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4> <h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
<p className="debug-image-query"> <p className="debug-image-query">
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`} {debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
</p> </p>
{debugLoading && <p className="debug-image-status">Searching</p>} {debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>} {debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && ( {!uploading && !debugLoading && debugSearch?.imageUrl && (
<img <img
className="debug-image-preview" className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, { src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -593,7 +615,7 @@ export default function PaintingDetailView({
}} }}
/> />
)} )}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && ( {!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p> <p className="debug-image-status">No Google image result found.</p>
)} )}
<div className="debug-action-buttons"> <div className="debug-action-buttons">
@@ -601,7 +623,7 @@ export default function PaintingDetailView({
type="button" type="button"
className="debug-checked-btn" className="debug-checked-btn"
onClick={handleMarkChecked} onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked} disabled={!!painting.checkup_checked || markingChecked || uploading}
> >
{markingChecked ? '…' : 'Checked'} {markingChecked ? '…' : 'Checked'}
</button> </button>
@@ -609,7 +631,7 @@ export default function PaintingDetailView({
type="button" type="button"
className="debug-fix-btn" className="debug-fix-btn"
onClick={handleFixImage} onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl} disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
> >
{fixing ? '…' : 'Fix it'} {fixing ? '…' : 'Fix it'}
</button> </button>
@@ -617,7 +639,7 @@ export default function PaintingDetailView({
type="button" type="button"
className="debug-more-btn" className="debug-more-btn"
onClick={handleOpenMore} onClick={handleOpenMore}
disabled={debugLoading || moreLoading} disabled={debugLoading || moreLoading || uploading}
> >
{moreLoading ? '…' : 'More'} {moreLoading ? '…' : 'More'}
</button> </button>
@@ -631,20 +653,11 @@ export default function PaintingDetailView({
> >
{clearing ? '…' : 'Clear'} {clearing ? '…' : 'Clear'}
</button> </button>
<button <DebugUploadButton
type="button" uploading={uploading}
className="debug-upload-btn" disabled={fixing || clearing}
onClick={handleUploadClick} onUploadPress={handleUploadPress}
disabled={uploading || fixing || clearing} onFileSelected={handleUploadFile}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
/> />
</div> </div>
<div className="debug-action-buttons debug-action-buttons-danger"> <div className="debug-action-buttons debug-action-buttons-danger">
@@ -661,7 +674,7 @@ export default function PaintingDetailView({
)} )}
<DebugSearchResultsModal <DebugSearchResultsModal
open={moreOpen} open={moreOpen && !uploading}
title="Choose painting image" title="Choose painting image"
data={moreResults} data={moreResults}
loading={moreLoading} loading={moreLoading}
@@ -671,9 +684,9 @@ export default function PaintingDetailView({
onSelect={handleSelectMoreResult} onSelect={handleSelectMoreResult}
/> />
{fullscreen && imageSrc && ( {fullscreen && displayImageSrc && (
<PaintingLightbox <PaintingLightbox
src={imageSrc} src={displayImageSrc}
alt={painting.title} alt={painting.title}
title={painting.title} title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : ''] subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
+5
View File
@@ -232,6 +232,8 @@ export default function HomePage() {
const patch: Partial<Painting> = { const patch: Partial<Painting> = {
image_path: fixResult.imagePath, image_path: fixResult.imagePath,
thumbnail_path: fixResult.thumbnailPath, thumbnail_path: fixResult.thumbnailPath,
image_cache_key: fixResult.image_cache_key ?? null,
thumbnail_cache_key: fixResult.thumbnail_cache_key ?? null,
checkup_checked: fixResult.checked ?? true, checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true, checkup_fixed: fixResult.fixed ?? true,
}; };
@@ -359,6 +361,9 @@ export default function HomePage() {
const data = await api.getArtist(artistId); const data = await api.getArtist(artistId);
const patch: Partial<Artist> = { const patch: Partial<Artist> = {
portrait_path: fixResult.portraitPath, portrait_path: fixResult.portraitPath,
portrait_thumb_path: fixResult.portraitThumbPath ?? null,
portrait_cache_key: fixResult.portrait_cache_key ?? null,
portrait_thumb_cache_key: fixResult.portrait_thumb_cache_key ?? null,
checkup_checked: fixResult.checked ?? true, checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true, checkup_fixed: fixResult.fixed ?? true,
}; };
+4
View File
@@ -32,6 +32,8 @@ export interface Artist {
movement_color?: string; movement_color?: string;
portrait_path: string | null; portrait_path: string | null;
portrait_thumb_path?: string | null; portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
bio_short?: string; bio_short?: string;
bio_full?: string; bio_full?: string;
wikipedia_title: string; wikipedia_title: string;
@@ -60,6 +62,8 @@ export interface Painting {
description: string; description: string;
image_path: string | null; image_path: string | null;
thumbnail_path?: string | null; thumbnail_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
wikipedia_title: string; wikipedia_title: string;
sort_order: number; sort_order: number;
artist_name?: string; artist_name?: string;
+45 -6
View File
@@ -18,6 +18,23 @@ const { printCliResult } = require('./lib/cli-result');
const { Client } = pg; const { Client } = pg;
// Prod restore keeps these tables untouched (no TRUNCATE, no INSERT from dev backup).
const PROD_RESTORE_SKIP_TABLES = new Set(['curator_audit_log']);
function getInsertTableName(statement) {
const match = statement.match(/^INSERT INTO "([^"]+)"/i)
|| statement.match(/^INSERT INTO ([a-zA-Z_][a-zA-Z0-9_]*)/i);
return match ? match[1] : null;
}
function filterInsertsForRestore(inserts, prod) {
if (!prod) return inserts;
return inserts.filter((statement) => {
const table = getInsertTableName(statement);
return !table || !PROD_RESTORE_SKIP_TABLES.has(table);
});
}
function parseArgs(argv) { function parseArgs(argv) {
const fileIdx = argv.indexOf('--file'); const fileIdx = argv.indexOf('--file');
if (fileIdx === -1 || !argv[fileIdx + 1]) { if (fileIdx === -1 || !argv[fileIdx + 1]) {
@@ -71,14 +88,18 @@ function extractInsertStatements(content) {
return statements; return statements;
} }
async function truncatePublicTables(client) { async function truncatePublicTables(client, { excludeTables = new Set() } = {}) {
const tablesRes = await client.query( const tablesRes = await client.query(
`SELECT table_name `SELECT table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE' WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`, ORDER BY table_name`,
); );
const names = tablesRes.rows.map((r) => `"${r.table_name}"`).join(', '); const names = tablesRes.rows
.map((r) => r.table_name)
.filter((name) => !excludeTables.has(name))
.map((name) => `"${name}"`)
.join(', ');
if (!names) return; if (!names) return;
await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`); await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`);
} }
@@ -92,14 +113,19 @@ async function main() {
const config = prod ? loadProdPgConfig() : loadDevPgConfig(); const config = prod ? loadProdPgConfig() : loadDevPgConfig();
const dbName = config.database; const dbName = config.database;
const inserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8')); const allInserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8'));
const inserts = filterInsertsForRestore(allInserts, prod);
const skippedInserts = prod ? allInserts.length - inserts.length : 0;
if (inserts.length === 0) { if (inserts.length === 0) {
throw new Error('No INSERT statements found in backup file'); throw new Error('No INSERT statements found in backup file');
} }
const skipNote = prod && PROD_RESTORE_SKIP_TABLES.size > 0
? `\nExcluded from prod restore: ${[...PROD_RESTORE_SKIP_TABLES].join(', ')}`
: '';
const prompt = prod const prompt = prod
? `RESTORE ${inserts.length} rows into PRODUCTION "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.` ? `RESTORE ${inserts.length} rows into PRODUCTION "${dbName}" from:\n ${filePath}\nThis TRUNCATES public tables first (except excluded tables).${skipNote}`
: `Restore ${inserts.length} rows into "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`; : `Restore ${inserts.length} rows into "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`;
if (prod) { if (prod) {
@@ -113,7 +139,12 @@ async function main() {
await client.connect(); await client.connect();
console.log(`Truncating public tables in "${dbName}"...`); console.log(`Truncating public tables in "${dbName}"...`);
await truncatePublicTables(client); if (prod && PROD_RESTORE_SKIP_TABLES.size > 0) {
console.log(` skipping: ${[...PROD_RESTORE_SKIP_TABLES].join(', ')}`);
}
await truncatePublicTables(client, {
excludeTables: prod ? PROD_RESTORE_SKIP_TABLES : new Set(),
});
// Fast path: disable FK/trigger checks for a single-pass load. Requires // Fast path: disable FK/trigger checks for a single-pass load. Requires
// permission to set session_replication_role (superuser, or on PG 15+ a // permission to set session_replication_role (superuser, or on PG 15+ a
@@ -186,12 +217,20 @@ async function main() {
await client.end(); await client.end();
console.log(`Restore complete: ${restored} statements into "${dbName}".`); console.log(`Restore complete: ${restored} statements into "${dbName}".`);
if (skippedInserts > 0) {
console.log(`Skipped ${skippedInserts} INSERT statement(s) for excluded prod tables.`);
}
printCliResult({ printCliResult({
script: 'restore-db-data', script: 'restore-db-data',
ok: true, ok: true,
summary: `Restore complete into "${dbName}".`, summary: `Restore complete into "${dbName}".`,
details: [`Statements restored: ${restored}`], details: [
`Statements restored: ${restored}`,
...(skippedInserts > 0
? [`Skipped (excluded tables): ${skippedInserts}`]
: []),
],
}); });
} }
+68 -7
View File
@@ -13,6 +13,59 @@ const FETCH_TIMEOUT_MS = 15000;
const PORTRAIT_THUMB_WIDTH = 256; const PORTRAIT_THUMB_WIDTH = 256;
const inflight = new Map(); const inflight = new Map();
function imageFileCacheKey(relPath) {
if (!relPath || typeof relPath !== 'string') return null;
const abs = path.join(IMAGE_DIR, relPath.replace(/^\//, ''));
try {
if (!fs.existsSync(abs)) return null;
return Math.floor(fs.statSync(abs).mtimeMs);
} catch {
return null;
}
}
function enrichPaintingRow(row) {
if (!row || typeof row !== 'object') return row;
return {
...row,
image_cache_key: imageFileCacheKey(row.image_path),
thumbnail_cache_key: imageFileCacheKey(row.thumbnail_path),
};
}
function enrichArtistRow(row) {
if (!row || typeof row !== 'object') return row;
return {
...row,
portrait_cache_key: imageFileCacheKey(row.portrait_path),
portrait_thumb_cache_key: imageFileCacheKey(row.portrait_thumb_path),
};
}
function paintingImagePayload(paths, extra = {}) {
const imagePath = paths.imagePath ?? paths.image_path ?? null;
const thumbnailPath = paths.thumbnailPath ?? paths.thumbnail_path ?? null;
return {
imagePath,
thumbnailPath,
image_cache_key: imageFileCacheKey(imagePath),
thumbnail_cache_key: imageFileCacheKey(thumbnailPath),
...extra,
};
}
function artistPortraitPayload(paths, extra = {}) {
const portraitPath = paths.portraitPath ?? paths.portrait_path ?? null;
const portraitThumbPath = paths.portraitThumbPath ?? paths.portrait_thumb_path ?? null;
return {
portraitPath,
portraitThumbPath,
portrait_cache_key: imageFileCacheKey(portraitPath),
portrait_thumb_cache_key: imageFileCacheKey(portraitThumbPath),
...extra,
};
}
function safePaintingBase(artistName, title) { function safePaintingBase(artistName, title) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_'); return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
} }
@@ -269,7 +322,7 @@ async function clearPaintingImage(paintingId) {
[paintingId] [paintingId]
); );
return { imagePath: null, thumbnailPath: null }; return paintingImagePayload({ imagePath: null, thumbnailPath: null });
} }
async function clearArtistPortrait(artistId) { async function clearArtistPortrait(artistId) {
@@ -290,7 +343,7 @@ async function clearArtistPortrait(artistId) {
[artistId] [artistId]
); );
return { portraitPath: null, portraitThumbPath: null }; return artistPortraitPayload({ portraitPath: null, portraitThumbPath: null });
} }
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) { async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
@@ -344,7 +397,7 @@ async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
[imagePath, thumbnailPath, paintingId] [imagePath, thumbnailPath, paintingId]
); );
return { imagePath, thumbnailPath }; return paintingImagePayload({ imagePath, thumbnailPath });
} }
async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) { async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
@@ -387,12 +440,14 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
fs.writeFileSync(fullDest, buffer); fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath; const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
} }
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath; const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
} }
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
@@ -436,7 +491,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
[imagePath, thumbnailPath, paintingId] [imagePath, thumbnailPath, paintingId]
); );
return { imagePath, thumbnailPath }; return paintingImagePayload({ imagePath, thumbnailPath });
} }
async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
@@ -480,7 +535,8 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath); const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath);
const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath; const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
} }
module.exports = { module.exports = {
@@ -493,5 +549,10 @@ module.exports = {
clearArtistPortrait, clearArtistPortrait,
replacePaintingImageFromBuffer, replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer, replaceArtistPortraitFromBuffer,
enrichPaintingRow,
enrichArtistRow,
paintingImagePayload,
artistPortraitPayload,
imageFileCacheKey,
IMAGE_DIR, IMAGE_DIR,
}; };
+9 -9
View File
@@ -11,7 +11,7 @@ const { createSessionMiddleware } = require('./middleware/session');
const { requireCurator } = require('./middleware/auth'); const { requireCurator } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log'); const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info'); const { getVersionInfo } = require('./version-info');
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
@@ -32,10 +32,10 @@ app.use('/api/auth', authRoutes);
app.use( app.use(
'/images', '/images',
express.static(IMAGE_DIR, { express.static(IMAGE_DIR, {
maxAge: '365d', etag: true,
immutable: true, lastModified: true,
setHeaders(res) { setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
}, },
}) })
); );
@@ -250,7 +250,7 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
res.json({ res.json({
movement: movement.rows[0], movement: movement.rows[0],
paintings: paintings.rows, paintings: paintings.rows.map(enrichPaintingRow),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -628,9 +628,9 @@ app.get('/api/artists/:id', async (req, res) => {
} }
res.json({ res.json({
artist: artist.rows[0], artist: enrichArtistRow(artist.rows[0]),
periods: periods.rows, periods: periods.rows,
paintings: paintings.rows, paintings: paintings.rows.map(enrichPaintingRow),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -786,9 +786,9 @@ app.get('/api/paintings/:id', async (req, res) => {
]); ]);
res.json({ res.json({
painting: painting.rows[0], painting: enrichPaintingRow(painting.rows[0]),
influencedBy: influencedBy.rows, influencedBy: influencedBy.rows,
influenced: influenced.rows, influenced: influenced.rows.map(enrichPaintingRow),
annotations: annotations.rows, annotations: annotations.rows,
}); });
} catch (err) { } catch (err) {