diff --git a/Documentation/API.md b/Documentation/API.md index 1199b89..ab2d3f2 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -96,11 +96,17 @@ Full artist profile for the bio page and 3D gallery entry. "wikipedia_title": "Claude Monet" }, "periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ], - "paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, ... } ] + "paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ] } ``` -Each painting includes `has_influence_links` (boolean) — `true` when the work appears in any `painting_influences` row as source or target. The 3D gallery uses this to show a golden lamp above the frame. +Each painting includes: + +| Field | Meaning | +|-------|---------| +| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame | +| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) | +| `checkup_fixed` | Image replaced via **Fix it** | Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)). @@ -163,7 +169,7 @@ Painting detail with influence graph neighbours. ```json { - "painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "has_influence_links": true, ... }, + "painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... }, "influencedBy": [ { "source_type": "painting", @@ -281,12 +287,21 @@ Google-family image search for debug / checkup (Custom Search → Google Arts & Download a remote URL and replace the painting’s local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`. +Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc. + **Body** ```json -{ "imageUrl": "https://…" } +{ + "imageUrl": "https://…", + "searchUrl": "https://…", + "source": "google-arts", + "thumbUrl": "https://…" +} ``` +Only `imageUrl` is required; optional fields improve fetch success for hotlinked URLs. + **Response** ```json @@ -325,9 +340,10 @@ The React client wraps these endpoints in `client/src/api/client.ts`: | `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` | | `imageUrl(path)` | `/images/` or placeholder | | `galleryImageUrl(painting)` | Local thumb/full only (3D) | +| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix | | `paintingImageUrl(painting)` | Local file or on-demand API | | `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` | -| `api.fixPaintingImage(id, imageUrl)` | `POST /api/paintings/:id/fix-image` | -| `debugImageProxyUrl(imageUrl)` | `GET /api/debug/image-proxy?url=…` | +| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` | +| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` | diff --git a/Documentation/basics.md b/Documentation/basics.md index fd2144e..9b5ff46 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -31,9 +31,14 @@ Gallery/ ├── server/ # Express API, DB pool, image service ├── client/ # React/Vite frontend │ ├── src/ # Source (components, pages, 3D scene) +│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit) +│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) +│ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) +│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor +│ │ ├── utils/debugMode.ts # Debug mode localStorage toggle │ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements │ └── dist/ # Production build (served by API when present) ├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools @@ -160,15 +165,20 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi |------|----------------| | One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings | | Catalog depth | Most artists target **≥ 6** notable works via `npm run expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps | -| Paintings on walls | Works hang on the **back, left, and right** walls; the room **grows and uses multiple rows** when the catalog is large (e.g. 75+ works) | +| Paintings on walls | Works hang on the **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large | +| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor | | Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left | +| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | +| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones | +| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | | Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) | | Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) | | Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects | -| Single exit | One doorway on the **front wall**; walk to it or click it | +| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow | | Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement | | Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) | | Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back | +| After image fix | Debug **Fix it** updates the gallery session, busts texture cache (`?v=N`), and returns to the hall with the new image and gold frame | **Controls:** @@ -213,6 +223,10 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another - **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable). - The 3D hall stays mounted in the background while detail is open so nothing is lost on return. +### Debug mode (developer) + +When **Debug mode** is enabled from the home header, painting detail shows a bottom-left panel with image search preview and **Checked** / **Fix it** buttons. See [Developer tools (image audit)](#developer-tools-image-audit). + ## Key design decisions - **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins. @@ -231,9 +245,22 @@ Optional workflow for curating local image files — not part of the public visi |---------|--------|---------| | **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail | | **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | -| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Google-family image search preview + **Fix it** for the current work | +| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Search preview + two action buttons | -**Checkup columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`). +### Debug panel (painting detail) + +When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **two buttons**: + +| Button | Action | +|--------|--------| +| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) | +| **Fix it** | Replaces local full + thumbnail from the search result via `POST /api/paintings/:id/fix-image`; sets **Fixed** and **Reviewed** | + +After **Fix it**, the detail image, gallery textures, and frame colour (gold if reviewed) update without a full page reload. **Back to Gallery** returns to the live hall session, not a stale snapshot. + +### Checkup page + +**Columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`). **Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**. diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 0ac990c..32d739c 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -337,7 +337,18 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab When **Debug mode** is on (home header) or from the **Checkup** page: 1. **Search** — `GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`). -2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets checkup flags. +2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets `checkup_fixed` + `checkup_checked`. + +### Painting detail debug panel + +With debug mode on, `PaintingDetail.tsx` shows a bottom-left panel with search preview and two buttons: + +| Button | API | Effect | +|--------|-----|--------| +| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold | +| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery | + +The client passes `searchUrl`, `source`, and `thumbUrl` from the search result to improve download reliability. After a fix, `HomePage` updates the gallery session and appends a revision query on 3D texture URLs so replaced files reload even when the path is unchanged. Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load. diff --git a/Documentation/setup.md b/Documentation/setup.md index 899b3dc..45d0304 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -168,4 +168,7 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate | | Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff | | Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes | +| **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) | +| 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 find-duplicates`; merge or delete spare rows manually | diff --git a/README.md b/README.md index 8967796..93cf122 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (dynamic wall layout, chronological wall order, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, preserved gallery camera on return, and Wikipedia-sourced artist biographies. +Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit (**Checked** / **Fix it**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. ## Documentation @@ -40,6 +40,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- npm run fetch-artist-bios # Wikipedia biographies for all artists npm run expand-catalog # add famous works for artists with thin catalogs npm run update-influences # art-history lineage links between paintings + npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode npm run fetch-images -- --limit=50 # random batch of missing images (10s per work) npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order ``` diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 85bea2c..9c0909c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -30,6 +30,19 @@ export function galleryImageUrl(painting: { return null; } +export function galleryImageUrlWithRevision( + painting: { + id?: number; + thumbnail_path?: string | null; + image_path?: string | null; + }, + revision?: number +): string | null { + const base = galleryImageUrl(painting); + if (!base || !revision) return base; + return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; +} + export function paintingImageUrl(painting: { id: number; image_path?: string | null; @@ -46,6 +59,13 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched: return res.json(); } +export interface FixPaintingImageResult { + imagePath: string; + thumbnailPath: string; + fixed?: boolean; + checked?: boolean; +} + export interface DebugImageSearchResult { query: string; imageUrl: string | null; @@ -100,17 +120,21 @@ export const api = { getPaintingDebugImageSearch: (id: number) => fetchJson(`${API}/paintings/${id}/debug-image-search`), - fixPaintingImage: (id: number, imageUrl: string) => + fixPaintingImage: ( + id: number, + imageUrl: string, + context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string } + ) => fetch(`${API}/paintings/${id}/fix-image`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imageUrl }), + body: JSON.stringify({ imageUrl, ...context }), }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Fix failed: ${res.status}`); } - return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>; + return res.json() as Promise; }), getPaintingCheckup: () => fetchJson(`${API}/paintings/checkup`), @@ -132,6 +156,12 @@ export const api = { }), }; -export function debugImageProxyUrl(imageUrl: string): string { - return `${API}/debug/image-proxy?url=${encodeURIComponent(imageUrl)}`; +export function debugImageProxyUrl( + imageUrl: string, + context?: { searchUrl?: string; source?: string } +): string { + const params = new URLSearchParams({ url: imageUrl }); + if (context?.searchUrl) params.set('searchUrl', context.searchUrl); + if (context?.source) params.set('source', context.source); + return `${API}/debug/image-proxy?${params.toString()}`; } diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index ce1770a..66ed327 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -440,6 +440,34 @@ word-break: break-word; } +.debug-action-buttons { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.debug-checked-btn { + flex: 1; + padding: 8px 10px; + border: 1px solid rgba(201, 169, 110, 0.45); + border-radius: 6px; + background: rgba(201, 169, 110, 0.1); + color: #c9a96e; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.debug-checked-btn:hover:not(:disabled) { + background: rgba(201, 169, 110, 0.22); + border-color: #c9a96e; +} + +.debug-checked-btn:disabled { + opacity: 0.55; + cursor: default; +} + .debug-image-preview { display: block; width: 100%; @@ -451,7 +479,7 @@ } .debug-fix-btn { - width: 100%; + flex: 1; padding: 8px 10px; border: 1px solid #e8a040; border-radius: 6px; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 2229623..9e5c450 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type SyntheticEvent } from 'react'; import type { InfluenceLink, Painting, PaintingDetail } from '../types'; -import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult } from '../api/client'; +import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client'; import PaintingLightbox from './PaintingLightbox'; import './PaintingDetail.css'; @@ -13,7 +13,14 @@ interface Props { onArtistBio: () => void; onInfluenceArtistClick?: (artistId: number) => void; debugMode?: boolean; - onPaintingImageFixed?: (paintingId: number) => void | Promise; + onPaintingImageFixed?: ( + paintingId: number, + fixResult: FixPaintingImageResult + ) => void | Promise; + onPaintingCheckupFlagsUpdated?: ( + paintingId: number, + flags: { checked: boolean; fixed: boolean } + ) => void | Promise; } function influenceKey(inf: InfluenceLink, index: number): string { @@ -180,6 +187,7 @@ export default function PaintingDetailView({ onInfluenceArtistClick, debugMode = false, onPaintingImageFixed, + onPaintingCheckupFlagsUpdated, }: Props) { const { painting, influencedBy, influenced } = data; const [fullscreen, setFullscreen] = useState(false); @@ -188,6 +196,7 @@ export default function PaintingDetailView({ const [debugLoading, setDebugLoading] = useState(false); const [debugError, setDebugError] = useState(null); const [fixing, setFixing] = useState(false); + const [markingChecked, setMarkingChecked] = useState(false); const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`; @@ -237,9 +246,15 @@ export default function PaintingDetailView({ setFixing(true); setDebugError(null); try { - await api.fixPaintingImage(painting.id, debugSearch.imageUrl); + const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, { + searchUrl: debugSearch.searchUrl, + source: debugSearch.source, + thumbUrl: debugSearch.thumbUrl, + }); setImageVersion((v) => v + 1); - await onPaintingImageFixed?.(painting.id); + if (onPaintingImageFixed) { + await onPaintingImageFixed(painting.id, fixResult); + } } catch (err) { setDebugError(err instanceof Error ? err.message : 'Could not replace image.'); } finally { @@ -247,6 +262,20 @@ export default function PaintingDetailView({ } }; + const handleMarkChecked = async () => { + if (painting.checkup_checked || markingChecked) return; + setMarkingChecked(true); + setDebugError(null); + try { + const updated = await api.updatePaintingCheckupFlags(painting.id, { checked: true }); + await onPaintingCheckupFlagsUpdated?.(painting.id, updated); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.'); + } finally { + setMarkingChecked(false); + } + }; + useEffect(() => { if (fullscreen) return; @@ -391,28 +420,39 @@ export default function PaintingDetailView({ {debugLoading &&

Searching…

} {debugError &&

{debugError}

} {!debugLoading && debugSearch?.imageUrl && ( - <> - {`Google { - (e.target as HTMLImageElement).src = '/placeholder-art.svg'; - }} - /> - - + {`Google { + (e.target as HTMLImageElement).src = '/placeholder-art.svg'; + }} + /> )} {!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (

No Google image result found.

)} +
+ + +
)} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 34897d8..110bbcf 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -9,12 +9,14 @@ import type { ArtistNavigation, MovementArtistGroup, } from '../types'; -import { galleryImageUrl, imageUrl, api, preloadArtistImages } from '../api/client'; +import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client'; import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils'; +import { createParquetFloorTexture, PARQUET_METERS_PER_TILE } from '../utils/parquetFloorTexture'; import './VirtualGallery.css'; interface Props { data: ArtistDetail; + imageRevisions?: Record; active?: boolean; onPaintingClick: (paintingId: number) => void; onNavigateArtist: (artistId: number) => void; @@ -28,20 +30,66 @@ const MOUNT_OFFSET = 0.16; const WALL_STANDOFF = 0.07; const BACK_WALL_EXTRA = 0.05; const FRAME_FACE_Z = 0.018; +const FRAME_DEPTH = 0.07; +const FRAME_MAT_BORDER = 0.1; +const FRAME_RAIL = 0.08; +/** Checked paintings — double-width moulding. */ +const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2; +const REVIEWED_RAIL = FRAME_RAIL * 2; const EYE_HEIGHT = 1.65; -const FRAME_GAP = 0.18; +const FRAME_GAP = 0.32; const MIN_FRAME_W = 0.45; const MAX_FRAME_W = 1.05; const MAX_FRAME_H = 1.35; const MIN_HALL_SIZE = 9; const ROW_GAP = 0.2; const WALL_PADDING = 1.4; +/** Above this count, use a long corridor (short back wall, extended side walls). */ +const CORRIDOR_CATALOG_THRESHOLD = 15; +const BACK_WALL_MAX_PAINTINGS = 8; +/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */ +const MAX_WALL_ROWS = 1; const DOOR_WIDTH = 2.4; const DOOR_HEIGHT = 2.5; +/** Museum exit — dimensions derived from door opening. */ +const EXIT_JAMB = 0.13; +const EXIT_HEADER = 0.15; +const EXIT_TRANSOM = 0.52; +const EXIT_SURROUND = 0.1; const TURN_SPEED = 0.032; const MOUSE_TURN_SENSITIVITY = 0.004; const DRAG_START_THRESHOLD_PX = 5; +const DEFAULT_MOVEMENT_COLOR = '#8B7355'; +const WALL_BASE_MAIN = '#f0ebe3'; +const WALL_BASE_SIDE = '#e8e2d8'; + +function parseHex(hex: string): { r: number; g: number; b: number } { + const n = parseInt(hex.replace('#', ''), 16); + if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 }; + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function blendHex(base: string, accent: string, accentWeight: number): string { + const a = parseHex(accent); + const b = parseHex(base); + const w = Math.min(1, Math.max(0, accentWeight)); + const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w)); + const r = mix(a.r, b.r); + const g = mix(a.g, b.g); + const bl = mix(a.b, b.b); + return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`; +} + +function galleryWallColors(movementColor?: string) { + const accent = movementColor || DEFAULT_MOVEMENT_COLOR; + return { + main: blendHex(WALL_BASE_MAIN, accent, 0.34), + side: blendHex(WALL_BASE_SIDE, accent, 0.26), + trim: blendHex('#b8956a', accent, 0.45), + }; +} + /** Height above frame top edge — keeps fixture out of the viewer's line of sight. */ const INFLUENCE_LAMP_ABOVE_FRAME = 0.46; @@ -68,34 +116,73 @@ interface HallLayout { segments: WallSegment[]; } -function layoutRow(count: number, span: number) { +function paintingIsReviewed(painting: Painting): boolean { + return !!painting.checkup_checked; +} + +function frameDimsForReviewed(reviewed: boolean) { + return reviewed + ? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 } + : { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH }; +} + +function frameOuterW(canvasW: number, reviewed: boolean): number { + const { matBorder, rail } = frameDimsForReviewed(reviewed); + return canvasW + matBorder * 2 + rail; +} + +function frameOuterH(canvasH: number, reviewed: boolean): number { + const { matBorder, rail } = frameDimsForReviewed(reviewed); + return canvasH + matBorder * 2 + rail; +} + +function layoutRow(paintings: Painting[], span: number) { + const count = paintings.length; + if (count === 0) return { slots: [], spanNeeded: span }; + const gap = FRAME_GAP; - const padding = 1.4; - const available = span - padding; - let frameW = Math.min(MAX_FRAME_W, (available - (count - 1) * gap) / Math.max(count, 1)); + const available = span - WALL_PADDING; + + let frameW = MAX_FRAME_W; + for (let attempt = 0; attempt < 40; attempt++) { + let total = 0; + for (let i = 0; i < count; i++) { + total += frameOuterW(frameW, paintingIsReviewed(paintings[i])); + if (i < count - 1) total += gap; + } + if (total <= available) break; + frameW -= 0.015; + } frameW = Math.max(MIN_FRAME_W, frameW); + const frameH = Math.min(MAX_FRAME_H, frameW * 1.22); - const rowWidth = count * frameW + (count - 1) * gap; + const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p))); + const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap; const slots: { offset: number; maxW: number; maxH: number }[] = []; + let cursor = -rowWidth / 2; for (let i = 0; i < count; i++) { + const outerW = outers[i]; slots.push({ - offset: -rowWidth / 2 + frameW / 2 + i * (frameW + gap), + offset: cursor + outerW / 2, maxW: frameW, maxH: frameH, }); + cursor += outerW + gap; } return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; } -function wallRowHeights(count: number, span: number, rows: number) { - const perRow = Math.ceil(count / rows); +function wallRowHeights(paintings: Painting[], span: number, rows: number) { + const perRow = Math.ceil(paintings.length / rows); const heights: number[] = []; for (let r = 0; r < rows; r++) { - const inRow = Math.min(perRow, count - r * perRow); - const { slots } = layoutRow(inRow, span); - heights.push(slots[0]?.maxH ?? MAX_FRAME_H); + const rowStart = r * perRow; + const row = paintings.slice(rowStart, rowStart + perRow); + const { slots } = layoutRow(row, span); + const reviewed = row.some(paintingIsReviewed); + heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed)); } return heights; } @@ -104,16 +191,14 @@ function wallStackHeight(heights: number[]) { return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0); } -function fitsOnWall(count: number, span: number, rows: number) { +function fitsOnWall(paintings: Painting[], span: number, rows: number) { + const count = paintings.length; const perRow = Math.ceil(count / rows); - const available = span - WALL_PADDING; - const frameW = Math.min( - MAX_FRAME_W, - (available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1) - ); - if (frameW < MIN_FRAME_W) return false; + const row = paintings.slice(0, perRow); + const { slots } = layoutRow(row, span); + if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false; - const totalHeight = wallStackHeight(wallRowHeights(count, span, rows)); + const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows)); const bottom = EYE_HEIGHT - totalHeight / 2; const top = EYE_HEIGHT + totalHeight / 2; if (bottom < 0.35) return false; @@ -121,26 +206,35 @@ function fitsOnWall(count: number, span: number, rows: number) { return true; } -function rowCountForWall(count: number, span: number) { - for (let rows = 1; rows <= count; rows++) { - if (fitsOnWall(count, span, rows)) return rows; +function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) { + const count = paintings.length; + const limit = Math.max(1, Math.min(count, maxRows)); + for (let rows = 1; rows <= limit; rows++) { + if (fitsOnWall(paintings, span, rows)) return rows; } - return count; + return limit; } -function minSpanForWall(count: number) { +function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) { + const count = paintings.length; if (count === 0) return MIN_HALL_SIZE; let best = Infinity; - for (let rows = 1; rows <= count; rows++) { + const rowLimit = Math.max(1, Math.min(count, maxRows)); + for (let rows = 1; rows <= rowLimit; rows++) { const perRow = Math.ceil(count / rows); - const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING; - if (fitsOnWall(count, span, rows)) { - best = Math.min(best, span); + const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE); + if (fitsOnWall(paintings, spanNeeded, rows)) { + best = Math.min(best, spanNeeded); } } - return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best); + if (best === Infinity) { + const perRow = Math.ceil(count / rowLimit); + best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded; + } + + return Math.max(MIN_HALL_SIZE, best); } /** Left → right on each wall: later works on the left, earlier works on the right. */ @@ -150,9 +244,30 @@ function orderPaintingsForWallDisplay(paintings: Painting[]) { function distributePaintingsAcrossWalls(paintings: Painting[]) { const sorted = [...paintings].sort(comparePaintingsChronological); - const walls: Painting[][] = [[], [], []]; - sorted.forEach((p, i) => walls[i % 3].push(p)); - return walls.map(orderPaintingsForWallDisplay); + const back: Painting[] = []; + const left: Painting[] = []; + const right: Painting[] = []; + + if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) { + sorted.forEach((p, i) => { + if (i % 3 === 0) back.push(p); + else if (i % 3 === 1) left.push(p); + else right.push(p); + }); + } else { + const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12))); + back.push(...sorted.slice(0, backCount)); + sorted.slice(backCount).forEach((p, i) => { + if (i % 2 === 0) left.push(p); + else right.push(p); + }); + } + + return [ + orderPaintingsForWallDisplay(back), + orderPaintingsForWallDisplay(left), + orderPaintingsForWallDisplay(right), + ]; } function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) { @@ -173,12 +288,13 @@ function layoutWallSlots( side: WallSide, halfW: number, halfD: number, - inset: number + inset: number, + maxRows: number = paintings.length ): FrameSlot[] { const count = paintings.length; if (count === 0) return []; - const rows = rowCountForWall(count, span); + const rows = rowCountForWall(paintings, span, maxRows); const perRow = Math.ceil(count / rows); const slots: FrameSlot[] = []; @@ -186,7 +302,7 @@ function layoutWallSlots( for (let r = 0; r < rows; r++) { const rowStart = r * perRow; const inRow = Math.min(perRow, count - rowStart); - rowLayouts.push(layoutRow(inRow, span)); + rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span)); } const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H); @@ -236,14 +352,17 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa const walls: WallSide[] = ['back', 'left', 'right']; const wallPaintings = distributePaintingsAcrossWalls(paintings); - let width = minSpanForWall(wallPaintings[0].length); - let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length)); + let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); + let depth = Math.max( + minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), + minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) + ); for (let i = 0; i < 24; i++) { - const nextWidth = minSpanForWall(wallPaintings[0].length); + const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); const nextDepth = Math.max( - minSpanForWall(wallPaintings[1].length), - minSpanForWall(wallPaintings[2].length) + minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), + minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) ); if (nextWidth === width && nextDepth === depth) break; width = nextWidth; @@ -267,7 +386,8 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa side, halfW, halfD, - inset + inset, + MAX_WALL_ROWS ), })); @@ -328,6 +448,25 @@ function getCanvasWeaveTexture() { return canvasWeaveTexture; } +function frameFinish(reviewed: boolean, hovered: boolean) { + if (reviewed) { + return { + color: hovered ? '#ffe566' : '#ffd700', + roughness: 0.22, + metalness: 0.78, + emissive: hovered ? '#ffcc00' : '#daa520', + emissiveIntensity: hovered ? 0.35 : 0.18, + }; + } + return { + color: hovered ? '#2a2a2a' : '#0a0a0a', + roughness: 0.28, + metalness: 0.72, + emissive: '#000000', + emissiveIntensity: 0, + }; +} + function CanvasCover({ width, height, @@ -510,6 +649,7 @@ function PaintingFrame({ maxWidth, maxHeight, wallSide, + imageRevision, onClick, }: { painting: Painting; @@ -518,19 +658,21 @@ function PaintingFrame({ maxWidth: number; maxHeight: number; wallSide: WallSide; + imageRevision?: number; onClick: () => void; }) { const [hovered, setHovered] = useState(false); const [aspect, setAspect] = useState(1.33); const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight); - const frameDepth = 0.06; - const matBorder = 0.05; + const reviewed = paintingIsReviewed(painting); + const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed); const hasImage = paintingHasGalleryImage(painting); - const url = hasImage ? galleryImageUrl(painting) : null; + const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null; const { texture, failed } = usePaintingTexture(url); const showImage = hasImage && !failed && !!texture; const showCanvas = !showImage; const hasInfluenceLinks = paintingHasInfluenceLinks(painting); + const finish = frameFinish(reviewed, hovered); const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0); useEffect(() => { @@ -563,18 +705,24 @@ function PaintingFrame({ onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)} > - + {!showCanvas && ( - + )} @@ -637,69 +785,311 @@ function ExitPortal({ position, active, onActivate, + wallColor = '#f0ebe3', + trimColor = '#ddd5c8', }: { position: [number, number, number]; active: boolean; onActivate: () => void; + wallColor?: string; + trimColor?: string; }) { const [hovered, setHovered] = useState(false); - const glow = active || hovered; + const highlight = active || hovered; + + const openingW = DOOR_WIDTH; + const openingH = DOOR_HEIGHT; + const jamb = EXIT_JAMB; + const header = EXIT_HEADER; + const transomH = EXIT_TRANSOM; + const surround = EXIT_SURROUND; + const frameW = openingW + jamb * 2; + const frameFullH = openingH + header + transomH; + const leafW = (openingW - 0.025) / 2; + const leafH = openingH - 0.08; + const leafY = 0.04 + leafH / 2; + const faceZ = -0.04; + + const woodDark = '#261a10'; + const woodMid = '#3d2818'; + const woodGrain = '#4e3624'; + const stone = wallColor; + const stoneDark = trimColor; + const brass = highlight ? '#d4af37' : '#a08050'; + const brassEmissive = highlight ? '#5a4010' : '#000000'; + const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0'; const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => { e.stopPropagation(); onActivate(); }; + const setHover = (on: boolean) => () => setHovered(on); + + const doorLeaf = (side: 'left' | 'right') => { + const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006; + const panelInset = 0.06; + return ( + + + + + + {/* Raised panel */} + + + + + + + + + {/* Stile edges */} + + + + + {/* Brass handle */} + + + + + + ); + }; + return ( - - + {/* Wall reveal — depth into opening */} + + + + + + {/* Warm vestibule glow beyond doors */} + + + - {/* Large invisible target — easier to click from anywhere in the hall */} - setHovered(true)} - onPointerOut={() => setHovered(false)} - > - - + {/* Marble threshold */} + + + + + + + - + + + + + {/* Side jambs */} + {([-1, 1] as const).map((sign) => ( + + + + + ))} + + {/* Header lintel */} + + + + + + {/* Transom — frosted museum glass */} + + + + + {/* Transom mullions */} + {[-0.35, 0, 0.35].map((ox) => ( + + + + + ))} + + {/* Limestone surround */} + {([-1, 1] as const).map((sign) => ( + + + + + ))} + + + + + + {/* Crown on surround */} + + + + + + {/* Brass EXIT plaque */} + + + + + + + EXIT + + + + {/* Wall sconces */} + {([-1, 1] as const).map((sign) => ( + + + + + + + + + + + + ))} + + {/* Hinges */} + {([-1, 1] as const).flatMap((side) => + [0.35, 0.85, 1.35].map((hy) => ( + + + + + )) + )} + + {/* Large invisible click target */} + setHovered(true)} - onPointerOut={() => setHovered(false)} + onPointerOver={setHover(true)} + onPointerOut={setHover(false)} > - EXIT - + + + ); } +function ParquetFloor({ width, depth }: { width: number; depth: number }) { + const floorW = width + 0.4; + const floorD = depth + 0.4; + const texture = useMemo(() => { + const map = createParquetFloorTexture(); + map.repeat.set(floorW / PARQUET_METERS_PER_TILE, floorD / PARQUET_METERS_PER_TILE); + map.needsUpdate = true; + return map; + }, [floorW, floorD]); + + return ( + + + + + ); +} + function ArtistHall({ layout, artistName, + movementColor, + imageRevisions, onPaintingClick, onExitActivate, nearExit, }: { layout: HallLayout; artistName: string; + movementColor?: string; + imageRevisions?: Record; onPaintingClick: (id: number) => void; onExitActivate: () => void; nearExit: boolean; @@ -707,52 +1097,56 @@ function ArtistHall({ const { width, depth, segments } = layout; const halfW = width / 2; const halfD = depth / 2; + const walls = useMemo(() => galleryWallColors(movementColor), [movementColor]); return ( {/* Floor & ceiling — open centre, no furniture */} - - - - + {/* Back wall */} - + {/* Left wall */} {/* Right wall */} {/* Front wall — two segments with door gap */} + {/* Wall section above door opening */} + {/* Crown molding on back wall */} - + {seg.paintings.map((painting, i) => ( onPaintingClick(painting.id)} /> ))} @@ -800,10 +1195,15 @@ function ArtistHall({ position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]} active={nearExit} onActivate={onExitActivate} + wallColor={walls.main} + trimColor={walls.trim} /> - + + {depth > 14 && ( + + )} ); } @@ -900,6 +1300,7 @@ function NavigationPanel({ export default function VirtualGallery({ data, + imageRevisions, active = true, onPaintingClick, onNavigateArtist, @@ -916,6 +1317,12 @@ export default function VirtualGallery({ const [nearExit, setNearExit] = useState(false); const [isLooking, setIsLooking] = useState(false); + useEffect(() => { + setArtist(data.artist); + setPeriods(data.periods); + setPaintings(data.paintings); + }, [data]); + useEffect(() => { let cancelled = false; (async () => { @@ -946,16 +1353,17 @@ export default function VirtualGallery({ const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]); - const halfW = layout.width / 2 - 0.8; - const halfD = layout.depth / 2 - 0.8; - const exitZ = layout.depth / 2 - 0.6; + const halfW = layout.width / 2 - 0.55; + const halfD = layout.depth / 2 - 0.35; + const exitZ = layout.depth / 2 - 0.55; + const fogFar = Math.max(55, layout.depth + 42); const initialPos = useMemo( () => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2), [layout.depth] ); const initialTarget = useMemo( - () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4), + () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)), [layout.depth] ); @@ -1141,7 +1549,7 @@ export default function VirtualGallery({ camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }} > - + @@ -1150,6 +1558,8 @@ export default function VirtualGallery({ {`Search: ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 })); setRows((list) => list.map((r) => diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index a4892cf..a87f073 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery'; import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; -import { api } from '../api/client'; +import { api, type FixPaintingImageResult } from '../api/client'; import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { readDebugMode, writeDebugMode } from '../utils/debugMode'; @@ -18,6 +18,17 @@ type View = | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } | { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View }; +function patchPaintingInArtistDetail( + detail: ArtistDetail, + paintingId: number, + patch: Partial +): ArtistDetail { + return { + ...detail, + paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)), + }; +} + export default function HomePage() { const [view, setView] = useState({ type: 'timeline' }); const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>( @@ -31,6 +42,7 @@ export default function HomePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [detailArtistPaintings, setDetailArtistPaintings] = useState([]); + const [imageRevisions, setImageRevisions] = useState>({}); const [debugMode, setDebugMode] = useState(readDebugMode); const detailReturnToRef = useRef({ type: 'timeline' }); @@ -88,42 +100,80 @@ export default function HomePage() { }); }; - const handlePaintingImageFixed = useCallback(async (paintingId: number) => { + const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const data = await api.getPainting(paintingId); - setView((current) => - current.type === 'painting' && current.paintingId === paintingId - ? { ...current, data } - : current - ); + const patch: Partial = { + image_path: fixResult.imagePath ?? data.painting.image_path, + thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path, + checkup_checked: fixResult.checked ?? true, + checkup_fixed: fixResult.fixed ?? true, + }; + const updatedData: PaintingDetail = { + ...data, + painting: { ...data.painting, ...patch }, + }; + + setImageRevisions((prev) => ({ ...prev, [paintingId]: (prev[paintingId] ?? 0) + 1 })); + + setView((current) => { + if (current.type !== 'painting' || current.paintingId !== paintingId) return current; + let returnTo = current.returnTo; + if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) { + returnTo = { + ...returnTo, + data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch), + }; + } + return { ...current, data: updatedData, returnTo }; + }); + setDetailArtistPaintings((list) => - list.map((p) => - p.id === paintingId - ? { ...p, image_path: data.painting.image_path, thumbnail_path: data.painting.thumbnail_path } - : p - ) + list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)) ); - if (gallerySession?.artistId === data.painting.artist_id) { + + setGallerySession((session) => + session && session.artistId === data.painting.artist_id + ? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) } + : session + ); + }, []); + + const handlePaintingCheckupFlagsUpdated = useCallback( + async (paintingId: number, flags: { checked: boolean; fixed: boolean }) => { + const data = await api.getPainting(paintingId); + const patch: Partial = { + checkup_checked: flags.checked, + checkup_fixed: flags.fixed, + }; + const updatedData: PaintingDetail = { + ...data, + painting: { ...data.painting, ...patch }, + }; + + setView((current) => { + if (current.type !== 'painting' || current.paintingId !== paintingId) return current; + let returnTo = current.returnTo; + if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) { + returnTo = { + ...returnTo, + data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch), + }; + } + return { ...current, data: updatedData, returnTo }; + }); + + setDetailArtistPaintings((list) => + list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)) + ); + setGallerySession((session) => - session - ? { - ...session, - data: { - ...session.data, - paintings: session.data.paintings.map((p) => - p.id === paintingId - ? { - ...p, - image_path: data.painting.image_path, - thumbnail_path: data.painting.thumbnail_path, - } - : p - ), - }, - } + session && session.artistId === data.painting.artist_id + ? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) } : session ); - } - }, [gallerySession]); + }, + [] + ); const handleArtistClick = async (artistId: number) => { try { @@ -210,6 +260,7 @@ export default function HomePage() {
setView(view.returnTo)} + onBack={() => { + const returnTo = view.returnTo; + if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) { + setView({ + type: 'gallery', + artistId: gallerySession.artistId, + data: gallerySession.data, + }); + } else { + setView(returnTo); + } + }} onPaintingClick={handlePaintingClick} onCatalogNavigate={handleCatalogNavigate} onArtistBio={async () => { @@ -234,6 +296,7 @@ export default function HomePage() { onInfluenceArtistClick={handleArtistClick} debugMode={debugMode} onPaintingImageFixed={handlePaintingImageFixed} + onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated} />
)} diff --git a/client/src/types/index.ts b/client/src/types/index.ts index b23a962..f627deb 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -61,6 +61,8 @@ export interface Painting { sort_order: number; artist_name?: string; has_influence_links?: boolean | string | number; + checkup_checked?: boolean; + checkup_fixed?: boolean; } export type InfluenceSourceType = 'painting' | 'artist' | 'movement'; @@ -99,7 +101,7 @@ export interface PaintingDetail { } export interface ArtistDetail { - artist: Artist & { movement_name: string }; + artist: Artist & { movement_name: string; movement_color?: string }; periods: ArtistPeriod[]; paintings: Painting[]; } diff --git a/client/src/utils/parquetFloorTexture.ts b/client/src/utils/parquetFloorTexture.ts new file mode 100644 index 0000000..c1b023d --- /dev/null +++ b/client/src/utils/parquetFloorTexture.ts @@ -0,0 +1,116 @@ +import * as THREE from 'three'; + +/** Warm oak tones with subtle variation — museum-style herringbone parquet. */ +const OAK_TONES = ['#6b4a2e', '#735234', '#624428', '#7a5638', '#5c3f26', '#6e4d30']; + +function shade(hex: string, amount: number): string { + const n = parseInt(hex.slice(1), 16); + const r = Math.min(255, Math.max(0, ((n >> 16) & 255) + amount)); + const g = Math.min(255, Math.max(0, ((n >> 8) & 255) + amount)); + const b = Math.min(255, Math.max(0, (n & 255) + amount)); + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`; +} + +function drawPlank( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + length: number, + width: number, + angle: number, + color: string +) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(angle); + + const grad = ctx.createLinearGradient(-length / 2, 0, length / 2, 0); + grad.addColorStop(0, shade(color, -12)); + grad.addColorStop(0.35, color); + grad.addColorStop(0.65, shade(color, 6)); + grad.addColorStop(1, shade(color, -8)); + ctx.fillStyle = grad; + ctx.fillRect(-length / 2, -width / 2, length, width); + + // Fine grain lines + ctx.strokeStyle = 'rgba(0,0,0,0.12)'; + ctx.lineWidth = 0.6; + for (let g = -length / 2 + 4; g < length / 2; g += 7) { + ctx.beginPath(); + ctx.moveTo(g, -width / 2 + 1); + ctx.lineTo(g + 3, width / 2 - 1); + ctx.stroke(); + } + + // Plank edge / grout + ctx.strokeStyle = 'rgba(18,10,4,0.55)'; + ctx.lineWidth = 1.1; + ctx.strokeRect(-length / 2, -width / 2, length, width); + + ctx.restore(); +} + +function paintHerringbone(ctx: CanvasRenderingContext2D, size: number) { + ctx.fillStyle = '#2a1810'; + ctx.fillRect(0, 0, size, size); + + const plankLen = 56; + const plankW = 14; + const step = plankLen * 0.707; // cos(45°) spacing for herringbone unit + + const cols = Math.ceil(size / step) + 3; + const rows = Math.ceil(size / step) + 3; + + for (let row = -1; row < rows; row++) { + for (let col = -1; col < cols; col++) { + const px = col * step; + const py = row * step; + const tone = OAK_TONES[(row * 5 + col * 7 + size) % OAK_TONES.length]; + + if ((row + col) % 2 === 0) { + drawPlank(ctx, px, py, plankLen, plankW, Math.PI / 4, tone); + drawPlank(ctx, px + step * 0.5, py + step * 0.5, plankLen, plankW, Math.PI / 4, shade(tone, -4)); + } else { + drawPlank(ctx, px + step * 0.25, py + step * 0.25, plankLen, plankW, -Math.PI / 4, tone); + drawPlank(ctx, px + step * 0.75, py + step * 0.75, plankLen, plankW, -Math.PI / 4, shade(tone, 5)); + } + } + } + + // Soft polish sheen + const sheen = ctx.createLinearGradient(0, 0, size, size); + sheen.addColorStop(0, 'rgba(255,240,210,0.06)'); + sheen.addColorStop(0.5, 'rgba(255,255,255,0)'); + sheen.addColorStop(1, 'rgba(255,230,200,0.04)'); + ctx.fillStyle = sheen; + ctx.fillRect(0, 0, size, size); +} + +let cachedTexture: THREE.CanvasTexture | null = null; + +/** Seamless herringbone parquet — shared across gallery halls. */ +export function createParquetFloorTexture(): THREE.CanvasTexture { + if (cachedTexture) return cachedTexture; + + const size = 512; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not create parquet canvas'); + + paintHerringbone(ctx, size); + + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 8; + texture.needsUpdate = true; + + cachedTexture = texture; + return texture; +} + +/** Meters covered by one texture repeat — tune for plank scale in the hall. */ +export const PARQUET_METERS_PER_TILE = 2.8; diff --git a/data/images/paintings/Albrecht_D_rer_Deposition.jpg b/data/images/paintings/Albrecht_D_rer_Deposition.jpg index 6b19866..6790870 100644 Binary files a/data/images/paintings/Albrecht_D_rer_Deposition.jpg and b/data/images/paintings/Albrecht_D_rer_Deposition.jpg differ diff --git a/data/images/paintings/Andrei_Rublev_Trinity.jpg b/data/images/paintings/Andrei_Rublev_Trinity.jpg deleted file mode 100644 index c2bbc50..0000000 Binary files a/data/images/paintings/Andrei_Rublev_Trinity.jpg and /dev/null differ diff --git a/data/images/paintings/Claude_Monet_Impression__Sunrise.jpg b/data/images/paintings/Claude_Monet_Impression__Sunrise.jpg index 94768d3..120bacf 100644 Binary files a/data/images/paintings/Claude_Monet_Impression__Sunrise.jpg and b/data/images/paintings/Claude_Monet_Impression__Sunrise.jpg differ diff --git a/data/images/paintings/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod.jpg b/data/images/paintings/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod.jpg new file mode 100644 index 0000000..cd12646 Binary files /dev/null and b/data/images/paintings/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod.jpg differ diff --git a/data/images/paintings/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel.jpg b/data/images/paintings/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel.jpg index 95c03df..b5c85e3 100644 Binary files a/data/images/paintings/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel.jpg and b/data/images/paintings/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel.jpg differ diff --git a/data/images/paintings/Fran_ois_Boucher_Cupid_a_Captive.jpg b/data/images/paintings/Fran_ois_Boucher_Cupid_a_Captive.jpg index f1171da..f832d0a 100644 Binary files a/data/images/paintings/Fran_ois_Boucher_Cupid_a_Captive.jpg and b/data/images/paintings/Fran_ois_Boucher_Cupid_a_Captive.jpg differ diff --git a/data/images/paintings/Fran_ois_Boucher_Madame_de_Pompadour.jpg b/data/images/paintings/Fran_ois_Boucher_Madame_de_Pompadour.jpg index f1171da..1fb7a68 100644 Binary files a/data/images/paintings/Fran_ois_Boucher_Madame_de_Pompadour.jpg and b/data/images/paintings/Fran_ois_Boucher_Madame_de_Pompadour.jpg differ diff --git a/data/images/paintings/Fran_ois_Boucher_Pastoral.jpg b/data/images/paintings/Fran_ois_Boucher_Pastoral.jpg index f1171da..8024050 100644 Binary files a/data/images/paintings/Fran_ois_Boucher_Pastoral.jpg and b/data/images/paintings/Fran_ois_Boucher_Pastoral.jpg differ diff --git a/data/images/paintings/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation.jpeg b/data/images/paintings/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation.jpeg new file mode 100644 index 0000000..16d7226 Binary files /dev/null and b/data/images/paintings/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation.jpeg differ diff --git a/data/images/paintings/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed_.jpg b/data/images/paintings/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed_.jpg index c9dd34a..91c0bee 100644 Binary files a/data/images/paintings/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed_.jpg and b/data/images/paintings/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed_.jpg differ diff --git a/data/images/paintings/Olga_Rozanova_Factory.jpg b/data/images/paintings/Olga_Rozanova_Factory.jpg index 002211e..52f0df2 100644 Binary files a/data/images/paintings/Olga_Rozanova_Factory.jpg and b/data/images/paintings/Olga_Rozanova_Factory.jpg differ diff --git a/data/images/paintings/Olga_Rozanova_Green_Stripe.jpg b/data/images/paintings/Olga_Rozanova_Green_Stripe.jpg deleted file mode 100644 index 002211e..0000000 Binary files a/data/images/paintings/Olga_Rozanova_Green_Stripe.jpg and /dev/null differ diff --git a/data/images/paintings/Olga_Rozanova_Green_Stripe.webp b/data/images/paintings/Olga_Rozanova_Green_Stripe.webp new file mode 100644 index 0000000..54e1f2a Binary files /dev/null and b/data/images/paintings/Olga_Rozanova_Green_Stripe.webp differ diff --git a/data/images/paintings/Olga_Rozanova_King_of_Clubs.jpg b/data/images/paintings/Olga_Rozanova_King_of_Clubs.jpg deleted file mode 100644 index 6baf491..0000000 Binary files a/data/images/paintings/Olga_Rozanova_King_of_Clubs.jpg and /dev/null differ diff --git a/data/images/paintings/Olga_Rozanova_King_of_Clubs.webp b/data/images/paintings/Olga_Rozanova_King_of_Clubs.webp new file mode 100644 index 0000000..248c71b Binary files /dev/null and b/data/images/paintings/Olga_Rozanova_King_of_Clubs.webp differ diff --git a/data/images/paintings/Olga_Rozanova_Non-objective_Composition.jpg b/data/images/paintings/Olga_Rozanova_Non-objective_Composition.jpg index 91c9ca6..1157ba1 100644 Binary files a/data/images/paintings/Olga_Rozanova_Non-objective_Composition.jpg and b/data/images/paintings/Olga_Rozanova_Non-objective_Composition.jpg differ diff --git a/data/images/paintings/Olga_Rozanova_Samovar.jpg b/data/images/paintings/Olga_Rozanova_Samovar.jpg index 002211e..ca4d2cb 100644 Binary files a/data/images/paintings/Olga_Rozanova_Samovar.jpg and b/data/images/paintings/Olga_Rozanova_Samovar.jpg differ diff --git a/data/images/paintings/Olga_Rozanova_The_Bridge.jpg b/data/images/paintings/Olga_Rozanova_The_Bridge.jpg index 002211e..4245f71 100644 Binary files a/data/images/paintings/Olga_Rozanova_The_Bridge.jpg and b/data/images/paintings/Olga_Rozanova_The_Bridge.jpg differ diff --git a/data/images/paintings/Pierre_Puvis_de_Chavannes_Summer.jpg b/data/images/paintings/Pierre_Puvis_de_Chavannes_Summer.jpg index 95a41c9..3b4f1e6 100644 Binary files a/data/images/paintings/Pierre_Puvis_de_Chavannes_Summer.jpg and b/data/images/paintings/Pierre_Puvis_de_Chavannes_Summer.jpg differ diff --git a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Dream.jpg b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Dream.jpg index 95a41c9..6434ab5 100644 Binary files a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Dream.jpg and b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Dream.jpg differ diff --git a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman.jpg b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman.jpg index 95a41c9..1df5f44 100644 Binary files a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman.jpg and b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman.jpg differ diff --git a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Sacred_Grove.jpg b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Sacred_Grove.jpg index 95a41c9..2055cea 100644 Binary files a/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Sacred_Grove.jpg and b/data/images/paintings/Pierre_Puvis_de_Chavannes_The_Sacred_Grove.jpg differ diff --git a/data/images/paintings/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep_.jpg b/data/images/paintings/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep_.jpg index 45dfa2f..9569550 100644 Binary files a/data/images/paintings/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep_.jpg and b/data/images/paintings/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep_.jpg differ diff --git a/data/images/paintings/Raphael_Half-length_portrait_of_a_young_woman_in_profile.jpg b/data/images/paintings/Raphael_Half-length_portrait_of_a_young_woman_in_profile.jpg new file mode 100644 index 0000000..4940eb1 Binary files /dev/null and b/data/images/paintings/Raphael_Half-length_portrait_of_a_young_woman_in_profile.jpg differ diff --git a/data/images/paintings/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca__.jpg b/data/images/paintings/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca__.jpg index 94f9f45..be0417b 100644 Binary files a/data/images/paintings/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca__.jpg and b/data/images/paintings/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca__.jpg differ diff --git a/data/images/paintings/Sandro_Botticelli_Mary_Magdalene_predella.jpg b/data/images/paintings/Sandro_Botticelli_Mary_Magdalene_predella.jpg deleted file mode 100644 index 2a94140..0000000 Binary files a/data/images/paintings/Sandro_Botticelli_Mary_Magdalene_predella.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Albrecht_D_rer_Deposition_thumb.jpg b/data/images/paintings/thumbs/Albrecht_D_rer_Deposition_thumb.jpg index 9b963f2..21a49ff 100644 Binary files a/data/images/paintings/thumbs/Albrecht_D_rer_Deposition_thumb.jpg and b/data/images/paintings/thumbs/Albrecht_D_rer_Deposition_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg b/data/images/paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg deleted file mode 100644 index c468f19..0000000 Binary files a/data/images/paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Claude_Monet_Impression__Sunrise_thumb.jpg b/data/images/paintings/thumbs/Claude_Monet_Impression__Sunrise_thumb.jpg index 817c5d9..22c7aa3 100644 Binary files a/data/images/paintings/thumbs/Claude_Monet_Impression__Sunrise_thumb.jpg and b/data/images/paintings/thumbs/Claude_Monet_Impression__Sunrise_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod_thumb.jpg b/data/images/paintings/thumbs/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod_thumb.jpg new file mode 100644 index 0000000..17317a3 Binary files /dev/null and b/data/images/paintings/thumbs/Duccio_Bethlehem_Mourns_Children_Killed_by_Herod_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel_thumb.jpg b/data/images/paintings/thumbs/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel_thumb.jpg index 1dc5e3c..2f6e37c 100644 Binary files a/data/images/paintings/thumbs/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel_thumb.jpg and b/data/images/paintings/thumbs/Duccio_The_Nativity_with_the_Prophets_Isaiah_and_Ezekiel_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Fran_ois_Boucher_Cupid_a_Captive_thumb.jpg b/data/images/paintings/thumbs/Fran_ois_Boucher_Cupid_a_Captive_thumb.jpg index 89a739e..bc2b76c 100644 Binary files a/data/images/paintings/thumbs/Fran_ois_Boucher_Cupid_a_Captive_thumb.jpg and b/data/images/paintings/thumbs/Fran_ois_Boucher_Cupid_a_Captive_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Fran_ois_Boucher_Madame_de_Pompadour_thumb.jpg b/data/images/paintings/thumbs/Fran_ois_Boucher_Madame_de_Pompadour_thumb.jpg index 89a739e..5d72302 100644 Binary files a/data/images/paintings/thumbs/Fran_ois_Boucher_Madame_de_Pompadour_thumb.jpg and b/data/images/paintings/thumbs/Fran_ois_Boucher_Madame_de_Pompadour_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Fran_ois_Boucher_Pastoral_thumb.jpg b/data/images/paintings/thumbs/Fran_ois_Boucher_Pastoral_thumb.jpg index 89a739e..d00baf5 100644 Binary files a/data/images/paintings/thumbs/Fran_ois_Boucher_Pastoral_thumb.jpg and b/data/images/paintings/thumbs/Fran_ois_Boucher_Pastoral_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Henri_Privat-Livemont_Absinthe_Robette_thumb.jpg b/data/images/paintings/thumbs/Henri_Privat-Livemont_Absinthe_Robette_thumb.jpg deleted file mode 100644 index 9e199ca..0000000 Binary files a/data/images/paintings/thumbs/Henri_Privat-Livemont_Absinthe_Robette_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation_thumb.jpg b/data/images/paintings/thumbs/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation_thumb.jpg new file mode 100644 index 0000000..f1ad4c8 Binary files /dev/null and b/data/images/paintings/thumbs/Jan_van_Eyck_Prophet_Zacharias__Angel_of_The_Annunciation_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed__thumb.jpg b/data/images/paintings/thumbs/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed__thumb.jpg index a1741bf..d115348 100644 Binary files a/data/images/paintings/thumbs/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed__thumb.jpg and b/data/images/paintings/thumbs/Jan_van_Eyck_The_Ghent_Altarpiece__wings_closed__thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_Factory_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_Factory_thumb.jpg index c768b24..2ae65dc 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_Factory_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_Factory_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_Green_Stripe_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_Green_Stripe_thumb.jpg index c768b24..7e34996 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_Green_Stripe_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_Green_Stripe_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_King_of_Clubs_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_King_of_Clubs_thumb.jpg index 3b7404a..ec5de56 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_King_of_Clubs_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_King_of_Clubs_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_Non-objective_Composition_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_Non-objective_Composition_thumb.jpg index 8ec24ba..4ff0eb7 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_Non-objective_Composition_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_Non-objective_Composition_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_Samovar_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_Samovar_thumb.jpg index c768b24..09b5059 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_Samovar_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_Samovar_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Olga_Rozanova_The_Bridge_thumb.jpg b/data/images/paintings/thumbs/Olga_Rozanova_The_Bridge_thumb.jpg index c768b24..f82ba61 100644 Binary files a/data/images/paintings/thumbs/Olga_Rozanova_The_Bridge_thumb.jpg and b/data/images/paintings/thumbs/Olga_Rozanova_The_Bridge_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_Summer_thumb.jpg b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_Summer_thumb.jpg index 78edaa7..bb4ce3d 100644 Binary files a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_Summer_thumb.jpg and b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_Summer_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Dream_thumb.jpg b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Dream_thumb.jpg index 78edaa7..d108585 100644 Binary files a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Dream_thumb.jpg and b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Dream_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman_thumb.jpg b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman_thumb.jpg index 78edaa7..2d5a5f2 100644 Binary files a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman_thumb.jpg and b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Poor_Fisherman_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Sacred_Grove_thumb.jpg b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Sacred_Grove_thumb.jpg index 78edaa7..49178c9 100644 Binary files a/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Sacred_Grove_thumb.jpg and b/data/images/paintings/thumbs/Pierre_Puvis_de_Chavannes_The_Sacred_Grove_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep__thumb.jpg b/data/images/paintings/thumbs/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep__thumb.jpg index 71dd748..1796842 100644 Binary files a/data/images/paintings/thumbs/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep__thumb.jpg and b/data/images/paintings/thumbs/Raphael_Christ_s_Charge_to_Peter__Feed_My_Sheep__thumb.jpg differ diff --git a/data/images/paintings/thumbs/Raphael_Half-length_portrait_of_a_young_woman_in_profile_thumb.jpg b/data/images/paintings/thumbs/Raphael_Half-length_portrait_of_a_young_woman_in_profile_thumb.jpg index 07156a9..4c215e6 100644 Binary files a/data/images/paintings/thumbs/Raphael_Half-length_portrait_of_a_young_woman_in_profile_thumb.jpg and b/data/images/paintings/thumbs/Raphael_Half-length_portrait_of_a_young_woman_in_profile_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca___thumb.jpg b/data/images/paintings/thumbs/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca___thumb.jpg index 01d94d4..42daf15 100644 Binary files a/data/images/paintings/thumbs/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca___thumb.jpg and b/data/images/paintings/thumbs/Raphael_Madonna_col_Bambino__for_the__Madonna_del_Granduca___thumb.jpg differ diff --git a/data/images/paintings/thumbs/Sandro_Botticelli_Mary_Magdalene_predella_thumb.jpg b/data/images/paintings/thumbs/Sandro_Botticelli_Mary_Magdalene_predella_thumb.jpg deleted file mode 100644 index 3d4cdab..0000000 Binary files a/data/images/paintings/thumbs/Sandro_Botticelli_Mary_Magdalene_predella_thumb.jpg and /dev/null differ diff --git a/scripts/image-fetcher.js b/scripts/image-fetcher.js index 0928588..c44d709 100644 --- a/scripts/image-fetcher.js +++ b/scripts/image-fetcher.js @@ -4,6 +4,8 @@ const fs = require('fs'); const path = require('path'); const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)'; +const BROWSER_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const WIKI_LANGS = ['de', 'fr', 'it', 'ru']; @@ -200,17 +202,20 @@ async function throttle() { lastRequestTime = Date.now(); } -function fetchBuffer(url, redirectCount = 0, referer = null) { +function fetchBuffer(url, redirectCount = 0, referer = null, options = {}) { return new Promise((resolve, reject) => { if (redirectCount > 8) return reject(new Error('Too many redirects')); const client = url.startsWith('https') ? https : http; + const useBrowser = options.browser !== false && (options.browser === true || !!referer); const headers = { - 'User-Agent': USER_AGENT, - Accept: 'image/*,*/*', + 'User-Agent': useBrowser ? BROWSER_USER_AGENT : USER_AGENT, + Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', }; if (referer) headers.Referer = referer; if (url.includes('wikimedia.org') || url.includes('wikipedia.org')) { headers.Referer = 'https://commons.wikimedia.org/'; + headers['User-Agent'] = USER_AGENT; } if (url.includes('artic.edu')) { headers.Referer = 'https://www.artic.edu/'; @@ -221,15 +226,15 @@ function fetchBuffer(url, redirectCount = 0, referer = null) { if (url.includes('hermitagemuseum.org') || url.includes('pushkinmuseum.art')) { headers.Referer = url.split('/').slice(0, 3).join('/') + '/'; } - if (url.includes('googleusercontent.com') || url.includes('artsandculture.google.com')) { - headers.Referer = 'https://artsandculture.google.com/'; + if (url.includes('googleusercontent.com') || url.includes('gstatic.com') || url.includes('artsandculture.google.com')) { + headers.Referer = headers.Referer || 'https://artsandculture.google.com/'; } const req = client.get(url, { headers }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { const next = res.headers.location.startsWith('http') ? res.headers.location : new URL(res.headers.location, url).href; - return resolve(fetchBuffer(next, redirectCount + 1, referer)); + return resolve(fetchBuffer(next, redirectCount + 1, referer, options)); } if (res.statusCode === 429) { res.resume(); @@ -1182,6 +1187,109 @@ async function downloadImageToFile(url, destPath, { force = false } = {}) { return destPath; } +function upgradeWikimediaToFull(url) { + const thumbMatch = url.match( + /^(https?:\/\/upload\.wikimedia\.org\/wikipedia\/(?:commons|en)\/)thumb\/(.+)\/\d+px-[^/]+$/ + ); + if (thumbMatch) return `${thumbMatch[1]}${thumbMatch[2]}`; + return url; +} + +function prepareDownloadCandidates(url, context = {}) { + const candidates = [url]; + if (context.thumbUrl && context.thumbUrl !== url) candidates.push(context.thumbUrl); + const google = upgradeGoogleUserContentUrl(url, FULL_WIDTH); + if (google !== url) candidates.push(google); + const wiki = upgradeWikimediaToFull(url); + if (wiki !== url) candidates.push(wiki); + try { + const parsed = new URL(url); + if (parsed.search) candidates.push(`${parsed.origin}${parsed.pathname}`); + } catch { + // ignore + } + return [...new Set(candidates)]; +} + +function referersForDownload(url, context = {}) { + const refs = []; + if (context.pageUrl) refs.push(context.pageUrl); + if (context.searchUrl) refs.push(context.searchUrl); + if (context.source === 'google-arts-culture') refs.push('https://artsandculture.google.com/'); + if (context.source === 'google-images' || context.source === 'google-custom-search') { + refs.push('https://www.google.com/'); + } + if (context.source === 'duckduckgo-images') refs.push('https://duckduckgo.com/'); + if (/googleusercontent\.com|gstatic\.com/i.test(url)) refs.push('https://artsandculture.google.com/'); + if (/wikimedia\.org|wikipedia\.org/i.test(url)) refs.push('https://commons.wikimedia.org/'); + try { + refs.push(new URL(url).origin + '/'); + } catch { + // ignore + } + return [...new Set(refs)]; +} + +/** Download with browser headers, referer fallbacks, and URL variants (fix / debug). */ +async function downloadImageForFix(url, destPath, context = {}) { + const candidates = prepareDownloadCandidates(url, context); + const referers = referersForDownload(url, context); + let lastError = null; + + await throttle(); + + for (const candidate of candidates) { + for (const referer of referers) { + try { + const buffer = await fetchBuffer(candidate, 0, referer, { browser: true }); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, buffer); + return destPath; + } catch (err) { + lastError = err; + await sleep(300); + } + } + } + + throw lastError || new Error('Download failed'); +} + +async function fetchImageBuffer(url, context = {}) { + const candidates = prepareDownloadCandidates(url, context); + const referers = referersForDownload(url, context); + let lastError = null; + + await throttle(); + + for (const candidate of candidates) { + for (const referer of referers) { + try { + return await fetchBuffer(candidate, 0, referer, { browser: true }); + } catch (err) { + lastError = err; + await sleep(300); + } + } + } + + throw lastError || new Error('Download failed'); +} + +function friendlyImageFetchError(err) { + const msg = err?.message || ''; + if (/ECONNRESET|ETIMEDOUT|EPIPE|socket hang up/i.test(msg)) { + return 'Could not download this image — the host closed the connection. Try Search again; Wikimedia and Google Arts results work best.'; + } + if (/HTTP 403|HTTP 401|HTTP 429/i.test(msg)) { + return 'Could not download this image — the host blocked the request. Try Search again or pick a different preview.'; + } + if (/Too many redirects/i.test(msg)) { + return 'Could not download this image — too many redirects from the image URL.'; + } + return msg || 'Could not download image'; +} + function decodeGoogleEmbeddedUrl(raw) { return raw .replace(/\\u003d/g, '=') @@ -1361,11 +1469,6 @@ async function searchGoogleImagesFirst(artistName, paintingTitle) { return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' }; } -async function fetchImageBuffer(url) { - await throttle(); - return withRetry(() => fetchBuffer(url)); -} - async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) { const sharp = require('sharp'); if (!fs.existsSync(fullDest)) return false; @@ -1493,11 +1596,13 @@ module.exports = { saveImageForItem, savePaintingImages, downloadImageToFile, + downloadImageForFix, generateThumbnailFromFull, searchWikipediaTitle, searchWebForPaintingImages, searchGoogleImagesFirst, fetchImageBuffer, + friendlyImageFetchError, pickExt, getGoogleArtsCultureImages, simplifyPaintingTitle, diff --git a/server/image-service.js b/server/image-service.js index 6224447..108adb7 100644 --- a/server/image-service.js +++ b/server/image-service.js @@ -3,7 +3,7 @@ const path = require('path'); const pool = require('./db'); const { savePaintingImages, - downloadImageToFile, + downloadImageForFix, generateThumbnailFromFull, pickExt, } = require('../scripts/image-fetcher'); @@ -154,7 +154,7 @@ async function ensurePaintingImages(paintingId, size = 'thumb') { return promise; } -async function replacePaintingImageFromUrl(paintingId, imageUrl) { +async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p @@ -182,7 +182,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl) { unlinkIfExists(fullDest); unlinkIfExists(thumbDest); - await downloadImageToFile(imageUrl, fullDest, { force: true }); + await downloadImageForFix(imageUrl, fullDest, context); let thumbnailPath = null; try { diff --git a/server/index.js b/server/index.js index d2217e1..beed53a 100644 --- a/server/index.js +++ b/server/index.js @@ -6,7 +6,7 @@ require('dotenv').config(); const pool = require('./db'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service'); -const { searchGoogleImagesFirst, fetchImageBuffer, pickExt } = require('../scripts/image-fetcher'); +const { searchGoogleImagesFirst, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const app = express(); const PORT = process.env.PORT || 3001; @@ -240,7 +240,7 @@ app.get('/api/artists/:id', async (req, res) => { const { id } = req.params; const [artist, periods, paintings] = await Promise.all([ pool.query( - `SELECT a.*, m.name as movement_name + `SELECT a.*, m.name as movement_name, m.color as movement_color FROM artists a LEFT JOIN art_movements m ON a.movement_id = m.id WHERE a.id = $1`, @@ -252,6 +252,8 @@ app.get('/api/artists/:id', async (req, res) => { ), pool.query( `SELECT p.*, + p.checkup_checked, + p.checkup_fixed, (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links FROM paintings p WHERE p.artist_id = $1 @@ -477,12 +479,17 @@ app.get('/api/paintings/:id/debug-image-search', async (req, res) => { app.post('/api/paintings/:id/fix-image', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); - const imageUrl = req.body?.imageUrl; + const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { return res.status(400).json({ error: 'Valid imageUrl required' }); } - const updated = await replacePaintingImageFromUrl(paintingId, imageUrl); + const updated = await replacePaintingImageFromUrl(paintingId, imageUrl, { + searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, + source: typeof source === 'string' ? source : undefined, + pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined, + thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined, + }); await pool.query( `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [paintingId] @@ -490,7 +497,7 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => { res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Fix image error:', err.message); - res.status(500).json({ error: err.message || 'Fix image failed' }); + res.status(500).json({ error: friendlyImageFetchError(err) }); } }); @@ -498,11 +505,16 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => { app.get('/api/debug/image-proxy', async (req, res) => { try { const imageUrl = req.query.url; + const searchUrl = req.query.searchUrl; + const source = req.query.source; if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { return res.status(400).json({ error: 'Valid url query required' }); } - const buffer = await fetchImageBuffer(imageUrl); + const buffer = await fetchImageBuffer(imageUrl, { + searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, + source: typeof source === 'string' ? source : undefined, + }); const ext = pickExt(imageUrl).toLowerCase(); const type = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';