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`.
**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:**
```powershell
@@ -440,7 +442,18 @@ Painting detail with influence graph neighbours.
```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": [
{
"source_type": "painting",
@@ -600,6 +613,8 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
{
"imagePath": "paintings/Artist_Title.jpg",
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"fixed": 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.getPainting(id)` | `GET /api/paintings/:id` |
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
| `imageUrl(path)` | `/images/<path>` or placeholder |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
| `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
| `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.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `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) |
| **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 |
| **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. | — |
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.
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`.
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).
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.
### 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 |
| **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 |
| **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 |
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.
+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:**
- `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 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 **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** — 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` |
| **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 |
| 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 |
+96 -23
View File
@@ -52,36 +52,63 @@ export async function logoutCurator(): Promise<void> {
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
}
export function imageUrl(path: string | null | undefined): string {
if (!path) return '/placeholder-art.svg';
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
export function imageUrl(path: string | null | undefined, revision?: number | null): string {
const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
if (!revision || base.startsWith('/placeholder')) return base;
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(
artist: {
portrait_thumb_path?: string | null;
portrait_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
},
revision?: number
revision?: number | null
): string {
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 */
export function galleryImageUrl(painting: {
export function galleryImageUrl(
painting: {
thumbnail_path?: string | null;
image_path?: string | null;
}): string | null {
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.image_path) return `/images/${painting.image_path}`;
image_cache_key?: number | null;
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;
}
@@ -90,27 +117,35 @@ export function galleryImageUrlWithRevision(
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
revision?: number
sessionRevision?: number | null
): string | null {
const base = galleryImageUrl(painting);
if (!base || !revision) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
return galleryImageUrl(painting, sessionRevision);
}
export function paintingImageUrl(painting: {
export function paintingImageUrl(
painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
checkup_fixed?: boolean;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
image_cache_key?: number | null;
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;
return `/api/paintings/${painting.id}/image?size=full`;
}
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
validateDebugUploadFile(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -122,7 +157,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
const comma = result.indexOf(',');
resolve({
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'));
@@ -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> {
const res = await fetch(url, {
...fetchCredentials,
@@ -156,12 +224,17 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}
export interface FixArtistPortraitResult {
portraitPath: string | null;
portraitThumbPath?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}
+1
View File
@@ -1,4 +1,5 @@
.artist-bio {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
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 {
api,
@@ -9,7 +9,10 @@ import {
type FixArtistPortraitResult,
} from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import '../components/PaintingDetail.css';
import '../components/GalleryLoadingMarker.css';
import './ArtistBio.css';
interface Props {
@@ -51,11 +54,13 @@ export default function ArtistBio({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = 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 showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
const portraitSrc = uploading
? null
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
const lifespan =
artist.birth_year && artist.death_year
@@ -71,6 +76,9 @@ export default function ArtistBio({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -91,7 +99,7 @@ export default function ArtistBio({
return () => {
cancelled = true;
};
}, [debugMode, artist.id, artist.name]);
}, [debugMode, uploading, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) {
@@ -197,20 +205,27 @@ export default function ArtistBio({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally {
setUploading(false);
@@ -219,6 +234,13 @@ export default function ArtistBio({
return (
<div className="artist-bio">
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="bio-header">
<button className="back-btn" onClick={onBack}> Back</button>
<h1>{artist.name}</h1>
@@ -227,9 +249,9 @@ export default function ArtistBio({
<div className="bio-content">
<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
src={portraitSrc}
alt={artist.name}
@@ -273,14 +295,17 @@ export default function ArtistBio({
</div>
{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>
<p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
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>
)}
<div className="debug-action-buttons">
@@ -301,7 +326,7 @@ export default function ArtistBio({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -309,7 +334,7 @@ export default function ArtistBio({
type="button"
className="debug-fix-btn"
onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -317,7 +342,7 @@ export default function ArtistBio({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -331,27 +356,18 @@ export default function ArtistBio({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
</aside>
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose portrait"
data={moreResults}
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;
}
.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 {
to {
transform: rotate(360deg);
@@ -6,6 +6,8 @@ interface Props {
overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */
banner?: boolean;
/** Inline row for small panels (debug upload, etc.). */
compact?: boolean;
className?: string;
}
@@ -13,12 +15,15 @@ export default function GalleryLoadingMarker({
message = 'Loading…',
overlay = false,
banner = false,
compact = false,
className = '',
}: Props) {
const modeClass = overlay
? ' gallery-loading-marker-overlay'
: banner
? ' gallery-loading-marker-banner'
: compact
? ' gallery-loading-marker-compact'
: '';
return (
+43 -2
View File
@@ -552,6 +552,48 @@
font-size: 12px;
font-weight: 600;
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 {
@@ -576,8 +618,7 @@
border-color: #8cbe8c;
}
.debug-clear-btn:disabled,
.debug-upload-btn:disabled {
.debug-clear-btn:disabled {
opacity: 0.6;
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 { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
import './GalleryLoadingMarker.css';
interface Props {
data: PaintingDetail;
@@ -214,15 +217,13 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
const imageSrc = baseImageUrl
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
const displayImageSrc = uploading ? null : imageSrc;
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -243,6 +244,7 @@ export default function PaintingDetailView({
setFixing(false);
setClearing(false);
setUploading(false);
setUploadStatus(null);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
@@ -255,6 +257,9 @@ export default function PaintingDetailView({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -275,7 +280,7 @@ export default function PaintingDetailView({
return () => {
cancelled = true;
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
@@ -382,20 +387,27 @@ export default function PaintingDetailView({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally {
setUploading(false);
@@ -437,6 +449,13 @@ export default function PaintingDetailView({
return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="painting-header">
<button className="back-btn" onClick={onBack}> Back to Gallery</button>
<div className="painting-title-block">
@@ -494,12 +513,12 @@ export default function PaintingDetailView({
)}
<div
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
role={imageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined}
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
role={displayImageSrc ? 'button' : undefined}
tabIndex={displayImageSrc ? 0 : undefined}
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={
imageSrc
displayImageSrc
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -508,14 +527,14 @@ export default function PaintingDetailView({
}
: undefined
}
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
title={displayImageSrc ? 'View full screen' : undefined}
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
{displayImageSrc ? (
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
{displayImageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
@@ -573,14 +592,17 @@ export default function PaintingDetailView({
</div>
{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>
<p className="debug-image-query">
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
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>
)}
<div className="debug-action-buttons">
@@ -601,7 +623,7 @@ export default function PaintingDetailView({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -609,7 +631,7 @@ export default function PaintingDetailView({
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -617,7 +639,7 @@ export default function PaintingDetailView({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -631,20 +653,11 @@ export default function PaintingDetailView({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
<div className="debug-action-buttons debug-action-buttons-danger">
@@ -661,7 +674,7 @@ export default function PaintingDetailView({
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose painting image"
data={moreResults}
loading={moreLoading}
@@ -671,9 +684,9 @@ export default function PaintingDetailView({
onSelect={handleSelectMoreResult}
/>
{fullscreen && imageSrc && (
{fullscreen && displayImageSrc && (
<PaintingLightbox
src={imageSrc}
src={displayImageSrc}
alt={painting.title}
title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
+5
View File
@@ -232,6 +232,8 @@ export default function HomePage() {
const patch: Partial<Painting> = {
image_path: fixResult.imagePath,
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_fixed: fixResult.fixed ?? true,
};
@@ -359,6 +361,9 @@ export default function HomePage() {
const data = await api.getArtist(artistId);
const patch: Partial<Artist> = {
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_fixed: fixResult.fixed ?? true,
};
+4
View File
@@ -32,6 +32,8 @@ export interface Artist {
movement_color?: string;
portrait_path: string | null;
portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
bio_short?: string;
bio_full?: string;
wikipedia_title: string;
@@ -60,6 +62,8 @@ export interface Painting {
description: string;
image_path: string | null;
thumbnail_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
wikipedia_title: string;
sort_order: number;
artist_name?: string;
+45 -6
View File
@@ -18,6 +18,23 @@ const { printCliResult } = require('./lib/cli-result');
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) {
const fileIdx = argv.indexOf('--file');
if (fileIdx === -1 || !argv[fileIdx + 1]) {
@@ -71,14 +88,18 @@ function extractInsertStatements(content) {
return statements;
}
async function truncatePublicTables(client) {
async function truncatePublicTables(client, { excludeTables = new Set() } = {}) {
const tablesRes = await client.query(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
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;
await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`);
}
@@ -92,14 +113,19 @@ async function main() {
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
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) {
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
? `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.`;
if (prod) {
@@ -113,7 +139,12 @@ async function main() {
await client.connect();
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
// permission to set session_replication_role (superuser, or on PG 15+ a
@@ -186,12 +217,20 @@ async function main() {
await client.end();
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
if (skippedInserts > 0) {
console.log(`Skipped ${skippedInserts} INSERT statement(s) for excluded prod tables.`);
}
printCliResult({
script: 'restore-db-data',
ok: true,
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 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) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
@@ -269,7 +322,7 @@ async function clearPaintingImage(paintingId) {
[paintingId]
);
return { imagePath: null, thumbnailPath: null };
return paintingImagePayload({ imagePath: null, thumbnailPath: null });
}
async function clearArtistPortrait(artistId) {
@@ -290,7 +343,7 @@ async function clearArtistPortrait(artistId) {
[artistId]
);
return { portraitPath: null, portraitThumbPath: null };
return artistPortraitPayload({ portraitPath: null, portraitThumbPath: null });
}
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
@@ -344,7 +397,7 @@ async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
return paintingImagePayload({ imagePath, thumbnailPath });
}
async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
@@ -387,12 +440,14 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
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 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 = {}) {
@@ -436,7 +491,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
return paintingImagePayload({ imagePath, thumbnailPath });
}
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 portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
}
module.exports = {
@@ -493,5 +549,10 @@ module.exports = {
clearArtistPortrait,
replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer,
enrichPaintingRow,
enrichArtistRow,
paintingImagePayload,
artistPortraitPayload,
imageFileCacheKey,
IMAGE_DIR,
};
+9 -9
View File
@@ -11,7 +11,7 @@ const { createSessionMiddleware } = require('./middleware/session');
const { requireCurator } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log');
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 { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
@@ -32,10 +32,10 @@ app.use('/api/auth', authRoutes);
app.use(
'/images',
express.static(IMAGE_DIR, {
maxAge: '365d',
immutable: true,
etag: true,
lastModified: true,
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({
movement: movement.rows[0],
paintings: paintings.rows,
paintings: paintings.rows.map(enrichPaintingRow),
});
} catch (err) {
console.error(err);
@@ -628,9 +628,9 @@ app.get('/api/artists/:id', async (req, res) => {
}
res.json({
artist: artist.rows[0],
artist: enrichArtistRow(artist.rows[0]),
periods: periods.rows,
paintings: paintings.rows,
paintings: paintings.rows.map(enrichPaintingRow),
});
} catch (err) {
console.error(err);
@@ -786,9 +786,9 @@ app.get('/api/paintings/:id', async (req, res) => {
]);
res.json({
painting: painting.rows[0],
painting: enrichPaintingRow(painting.rows[0]),
influencedBy: influencedBy.rows,
influenced: influenced.rows,
influenced: influenced.rows.map(enrichPaintingRow),
annotations: annotations.rows,
});
} catch (err) {