From 0a5918c4ddf06cf268f6a38e761af1b8d9282200 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Wed, 29 Jul 2026 19:05:54 +0300 Subject: [PATCH] Add movement artist filter modal and fix large-hall texture blanks Clicking a movement opens ArtistFilterModal to choose artists before the gallery. Large halls no longer permanently blank frames when texture loads exceed the overlay deadline; fall back thumb to full to on-demand API. Co-authored-by: Cursor --- Documentation/API.md | 31 ++- Documentation/basics.md | 16 +- client/src/api/client.ts | 28 +++ client/src/components/ArtistFilterModal.css | 229 ++++++++++++++++++++ client/src/components/ArtistFilterModal.tsx | 171 +++++++++++++++ client/src/components/VirtualGallery.tsx | 48 ++-- client/src/pages/HomePage.tsx | 53 ++++- client/src/types/index.ts | 13 ++ server/index.js | 23 ++ 9 files changed, 589 insertions(+), 23 deletions(-) create mode 100644 client/src/components/ArtistFilterModal.css create mode 100644 client/src/components/ArtistFilterModal.tsx diff --git a/Documentation/API.md b/Documentation/API.md index 93086b7..29b4367 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -373,9 +373,38 @@ Artists belonging to a single movement. --- +## `GET /api/movements/:id/artists-summary` + +Lightweight artist list for the **movement gallery entry filter** modal (portraits + painting counts). + +**Response** — array of: + +```json +{ + "id": 6, + "name": "Andrei Rublev", + "birth_year": 1360, + "death_year": 1430, + "portrait_path": "portraits/...", + "portrait_thumb_path": "portraits/thumbs/...", + "portrait_cache_key": 1710000000000, + "portrait_thumb_cache_key": 1710000000000, + "painting_count": 12 +} +``` + +| Field | Meaning | +|-------|---------| +| `painting_count` | Number of paintings by this artist (any movement filter is by `artists.movement_id`) | +| `portrait_*_cache_key` | File mtimes for cache-busting (from `enrichArtistRow`) | + +Ordered by `birth_year`, then name. Localized when `?lang=` / locale headers request a non-default locale. + +--- + ## `GET /api/movements/:id/gallery` -Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page). +Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page, after the artist-filter modal). **Response** diff --git a/Documentation/basics.md b/Documentation/basics.md index 82e8d25..6163e16 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -8,7 +8,7 @@ The app is organised as a **drill-down hierarchy**: 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. -3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right). +3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right). 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). @@ -209,9 +209,9 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view |--------|------| | Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes | | Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) | -| Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready | +| Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready | | Overlay **“Loading gallery…”** | While the 3D canvas initializes, HDR Environment settles, or door/hall shaders warm up after the hall opens (painting images continue loading after the overlay dismisses) | -| Overlay **“Loading paintings…”** | While wall painting textures are still downloading / uploading to the GPU | +| Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame | | Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts | View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx` → `handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders. @@ -272,7 +272,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row: | Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) | | Drag on flow canvas | Pan | | Click portrait | Open artist biography | -| Click **movement name** (label on stream) | Open **movement gallery** for that movement | +| Click **movement name** (label on stream) | Open the **artist filter** modal for that movement, then the movement gallery | **Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop. @@ -326,11 +326,11 @@ Predecessors and successors come from **`painting_influence_sources`** (painting ### Movement galleries -Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx` → `GET /api/movements/:id/gallery`). +Enter from the home page by clicking a **movement name** on the movement flow. First a centered **artist filter** modal (`ArtistFilterModal.tsx`) loads `GET /api/movements/:id/artists-summary` (portrait, lifespan, painting count). All artists are selected by default; deselect any to exclude their works, then **Enter gallery**. The client loads `GET /api/movements/:id/gallery` and filters paintings to the selected artist IDs before opening `VirtualGallery`. | Rule | Implementation | |------|----------------| -| One gallery per movement | All paintings by artists in that movement, sorted chronologically | +| One gallery per movement | Paintings by **selected** artists in that movement, sorted chronologically | | Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings | | Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit | | Wall order | Same U-shaped hang as artist halls (left → end → right) | @@ -373,9 +373,9 @@ Full guide: [tours.md](tours.md). ### Shared 3D behaviour -**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an **artist** or **movement** hall (public routes — link disk files and regenerate missing thumbs). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. +**3D images** prefer local thumbnail files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → full → `GET /api/paintings/:id/image?size=thumb`). Remote Wikipedia fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an **artist** or **movement** hall (public routes — link disk files and regenerate missing thumbs). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later (important for large halls such as Byzantine). -**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; per-image timeouts; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. +**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; fallback to full / on-demand API; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. **WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene. diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5cda8ca..30a07c1 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -5,6 +5,7 @@ import type { Artist, ArtistDetail, MovementGalleryDetail, + ArtistSummary, Painting, PaintingDetail, ArtistNavigation, @@ -176,6 +177,30 @@ export function galleryImageUrl( return null; } +/** Ordered texture URL candidates for a hall frame (thumb → full → on-demand API). */ +export function galleryImageUrlCandidates( + painting: { + id?: number; + thumbnail_path?: string | null; + image_path?: string | null; + image_cache_key?: number | null; + thumbnail_cache_key?: number | null; + }, + sessionRevision?: number | null +): string[] { + const revision = paintingImageRevision(painting, sessionRevision); + const urls: string[] = []; + const push = (u: string | null | undefined) => { + if (u && !urls.includes(u)) urls.push(u); + }; + if (painting.thumbnail_path) push(imageUrl(painting.thumbnail_path, revision)); + if (painting.image_path) push(imageUrl(painting.image_path, revision)); + if (painting.id != null) { + push(`/api/paintings/${painting.id}/image?size=thumb`); + } + return urls; +} + export function galleryImageUrlWithRevision( painting: { id?: number; @@ -458,6 +483,9 @@ export const api = { getMovementGallery: (id: number) => fetchJson(localizedPath(`${API}/movements/${id}/gallery`)), + getMovementArtistsSummary: (id: number) => + fetchJson(localizedPath(`${API}/movements/${id}/artists-summary`)), + getArtistNavigation: (id: number) => fetchJson(localizedPath(`${API}/artists/${id}/navigation`)), diff --git a/client/src/components/ArtistFilterModal.css b/client/src/components/ArtistFilterModal.css new file mode 100644 index 0000000..c65d526 --- /dev/null +++ b/client/src/components/ArtistFilterModal.css @@ -0,0 +1,229 @@ +.artist-filter-backdrop { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.65); + padding: 16px; +} + +.artist-filter-modal { + width: min(100%, 720px); + max-height: min(90vh, 640px); + display: flex; + flex-direction: column; + border-radius: 10px; + border: 1px solid rgba(201, 169, 110, 0.35); + background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); +} + +.artist-filter-header { + padding: 20px 24px 12px; + flex-shrink: 0; +} + +.artist-filter-header h2 { + margin: 0 0 6px; + font-family: 'Georgia', serif; + font-size: 22px; + color: #e8d5b5; +} + +.artist-filter-subtitle { + margin: 0; + font-size: 13px; + color: rgba(201, 169, 110, 0.7); +} + +.artist-filter-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 24px 12px; + flex-shrink: 0; +} + +.artist-filter-toggle-all { + border: none; + background: transparent; + color: rgba(255, 220, 160, 0.9); + font-size: 13px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.artist-filter-toggle-all:hover { + color: #ffe8c0; +} + +.artist-filter-count { + font-size: 12px; + color: rgba(201, 169, 110, 0.65); +} + +.artist-filter-grid { + flex: 1; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + padding: 4px 24px 16px; +} + +.artist-filter-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + border-radius: 8px; + border: 1px solid rgba(201, 169, 110, 0.2); + background: rgba(0, 0, 0, 0.25); + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s, opacity 0.15s; +} + +.artist-filter-card:hover { + border-color: rgba(255, 220, 160, 0.45); + background: rgba(0, 0, 0, 0.35); +} + +.artist-filter-card-selected { + border-color: rgba(255, 220, 160, 0.55); + background: rgba(255, 220, 160, 0.08); +} + +.artist-filter-card:not(.artist-filter-card-selected) { + opacity: 0.55; +} + +.artist-filter-portrait-wrap { + position: relative; + width: 56px; + height: 56px; + margin-bottom: 4px; +} + +.artist-filter-portrait { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(201, 169, 110, 0.4); +} + +.artist-filter-portrait-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: rgba(201, 169, 110, 0.15); + color: rgba(232, 213, 181, 0.6); + font-size: 20px; + font-family: 'Georgia', serif; +} + +.artist-filter-check { + position: absolute; + right: -2px; + bottom: -2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(255, 220, 160, 0.95); + color: #1a1a2e; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.artist-filter-card:not(.artist-filter-card-selected) .artist-filter-check { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(201, 169, 110, 0.35); + color: transparent; +} + +.artist-filter-name { + font-family: 'Georgia', serif; + font-size: 13px; + font-weight: 600; + color: #e8d5b5; + line-height: 1.25; +} + +.artist-filter-years { + font-size: 11px; + color: rgba(201, 169, 110, 0.75); +} + +.artist-filter-paintings { + font-size: 11px; + color: rgba(201, 169, 110, 0.55); +} + +.artist-filter-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px 20px; + border-top: 1px solid rgba(201, 169, 110, 0.15); + flex-shrink: 0; +} + +.artist-filter-cancel, +.artist-filter-proceed { + padding: 10px 18px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; +} + +.artist-filter-cancel { + border: 1px solid rgba(201, 169, 110, 0.35); + background: transparent; + color: rgba(232, 213, 181, 0.85); +} + +.artist-filter-cancel:hover { + background: rgba(201, 169, 110, 0.1); +} + +.artist-filter-proceed { + border: none; + background: linear-gradient(180deg, #c9a96e 0%, #a88b4a 100%); + color: #1a1a2e; + font-weight: 600; +} + +.artist-filter-proceed:hover:not(:disabled) { + filter: brightness(1.08); +} + +.artist-filter-proceed:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +@media (max-width: 480px) { + .artist-filter-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 8px; + padding-left: 16px; + padding-right: 16px; + } + + .artist-filter-header, + .artist-filter-toolbar, + .artist-filter-footer { + padding-left: 16px; + padding-right: 16px; + } +} diff --git a/client/src/components/ArtistFilterModal.tsx b/client/src/components/ArtistFilterModal.tsx new file mode 100644 index 0000000..8638737 --- /dev/null +++ b/client/src/components/ArtistFilterModal.tsx @@ -0,0 +1,171 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { ArtistSummary } from '../types'; +import { portraitThumbUrl, portraitUrl } from '../api/client'; +import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc'; +import './ArtistFilterModal.css'; + +interface Props { + open: boolean; + movementName: string; + artists: ArtistSummary[]; + portraitRevisions?: Record; + onProceed: (selectedIds: Set) => void; + onClose: () => void; +} + +function formatLifespan(birth: number | null, death: number | null): string { + const b = birth != null ? String(birth) : '?'; + const d = death != null ? String(death) : '?'; + return `${b}–${d}`; +} + +function paintingCountLabel(count: number): string { + return count === 1 ? '1 painting' : `${count} paintings`; +} + +function ArtistFilterPortrait({ + artist, + revision, +}: { + artist: ArtistSummary; + revision?: number; +}) { + const thumbSrc = + artist.portrait_thumb_path || artist.portrait_path + ? portraitThumbUrl(artist, revision) + : null; + const fullSrc = artist.portrait_path + ? portraitUrl(artist.portrait_path, revision ?? artist.portrait_cache_key) + : null; + const [srcIndex, setSrcIndex] = useState(0); + const candidates = [thumbSrc, fullSrc].filter((s, i, arr): s is string => Boolean(s) && arr.indexOf(s) === i); + const requested = candidates[srcIndex] ?? null; + const queuedSrc = useQueuedImageSrc(requested); + + useEffect(() => { + setSrcIndex(0); + }, [thumbSrc, fullSrc]); + + if (!queuedSrc) { + return ( + + ? + + ); + } + + return ( + { + setSrcIndex((i) => (i + 1 < candidates.length ? i + 1 : candidates.length)); + }} + /> + ); +} + +export default function ArtistFilterModal({ + open, + movementName, + artists, + portraitRevisions, + onProceed, + onClose, +}: Props) { + const allIds = useMemo(() => new Set(artists.map((a) => a.id)), [artists]); + const [selected, setSelected] = useState>(() => new Set(allIds)); + + useEffect(() => { + if (open) { + setSelected(new Set(artists.map((a) => a.id))); + } + }, [open, artists]); + + if (!open) return null; + + const allSelected = selected.size === artists.length && artists.length > 0; + const noneSelected = selected.size === 0; + + const toggleArtist = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + setSelected(allSelected ? new Set() : new Set(allIds)); + }; + + return ( +
+
e.stopPropagation()} + > +
+

{movementName}

+

Choose artists to include in the gallery hall

+
+ +
+ + + {selected.size} of {artists.length} selected + +
+ +
+ {artists.map((artist) => { + const isSelected = selected.has(artist.id); + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 19a182e..97b0184 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -12,7 +12,7 @@ import type { MovementArtistGroup, TourGalleryDetail, } from '../types'; -import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client'; +import { galleryImageUrlCandidates, imageUrl, api } from '../api/client'; import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; @@ -647,20 +647,32 @@ function CanvasCover({ ); } -function usePaintingTexture(url: string | null) { +function usePaintingTexture(urls: string[] | string | null) { + const candidates = useMemo(() => { + const list = Array.isArray(urls) ? urls.filter(Boolean) : urls ? [urls] : []; + return list; + }, [Array.isArray(urls) ? urls.join('|') : urls ?? '']); + const candidateKey = candidates.join('|'); + const [urlIndex, setUrlIndex] = useState(0); + const url = candidates[urlIndex] ?? null; const [texture, setTexture] = useState(null); - const [failed, setFailed] = useState(!url); + const [failed, setFailed] = useState(candidates.length === 0); const textureLoad = useContext(GalleryTextureLoadContext); const { gl } = useThree(); + useEffect(() => { + setUrlIndex(0); + }, [candidateKey]); + useEffect(() => { if (!url) { setTexture(null); - setFailed(true); + setFailed(candidates.length === 0 || urlIndex >= candidates.length); return; } setFailed(false); + setTexture(null); let disposed = false; let loaded: THREE.Texture | null = null; let settled = false; @@ -674,9 +686,12 @@ function usePaintingTexture(url: string | null) { }; textureLoad?.begin(); + // Release the loading overlay after a deadline, but do NOT mark the texture + // failed — large halls (e.g. Byzantine, 45 works) queue behind ~6 browser + // connections and often finish after 10s. Marking failed permanently left + // blank canvases even when the image arrived later. const loadTimeout = window.setTimeout(() => { if (settled || disposed) return; - setFailed(true); finish(); }, TEXTURE_LOAD_TIMEOUT_MS); @@ -692,17 +707,21 @@ function usePaintingTexture(url: string | null) { const img = tex.image as HTMLImageElement | undefined; if (!img || img.width < 4 || img.height < 4) { tex.dispose(); - setFailed(true); finish(); + if (!disposed) { + if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); + else setFailed(true); + } return; } loaded = tex; tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; - // Release the hall overlay counter before GPU upload — large halls - // (50+ works) otherwise stay on "Loading paintings…" for a long time. finish(); - if (!disposed) setTexture(tex); + if (!disposed) { + setFailed(false); + setTexture(tex); + } try { if (!disposed) gl.initTexture(tex); } catch { @@ -713,7 +732,10 @@ function usePaintingTexture(url: string | null) { () => { window.clearTimeout(loadTimeout); finish(); - if (!disposed) setFailed(true); + if (!disposed) { + if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); + else setFailed(true); + } } ); @@ -724,7 +746,7 @@ function usePaintingTexture(url: string | null) { loaded?.dispose(); setTexture(null); }; - }, [url, textureLoad, gl]); + }, [url, urlIndex, candidates.length, textureLoad, gl]); return { texture, failed }; } @@ -877,8 +899,8 @@ function PaintingFrame({ const reviewed = paintingIsReviewed(painting); const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed); const hasImage = paintingHasGalleryImage(painting); - const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null; - const { texture, failed } = usePaintingTexture(url); + const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : []; + const { texture, failed } = usePaintingTexture(urls); const showImage = hasImage && !failed && !!texture; const showCanvas = !showImage; const hasInfluenceLinks = paintingHasInfluenceLinks(painting); diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index cec434b..2c42a05 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -12,12 +12,14 @@ import InfluencesPage from '../pages/InfluencesPage'; import ToursPage from '../pages/ToursPage'; import UsersPage from '../pages/UsersPage'; import CuratorLoginModal from '../components/CuratorLoginModal'; +import ArtistFilterModal from '../components/ArtistFilterModal'; import ToursPopup from '../components/ToursPopup'; import CatalogSearchBar from '../components/CatalogSearchBar'; import LocaleSwitcher from '../components/LocaleSwitcher'; import GalleryLoadingMarker from '../components/GalleryLoadingMarker'; import '../components/CatalogSearchBar.css'; import '../components/CuratorLoginModal.css'; +import '../components/ArtistFilterModal.css'; import '../components/ToursPopup.css'; import '../components/LocaleSwitcher.css'; import '../pages/TranslationsPage.css'; @@ -34,6 +36,7 @@ import type { PaintingDetail, MovementGalleryDetail, TourGalleryDetail, + ArtistSummary, } from '../types'; import { createViewChangeScheduler } from '../utils/timelineView'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; @@ -178,6 +181,11 @@ export default function HomePage() { const [loginOpen, setLoginOpen] = useState(false); const [loginRedirect, setLoginRedirect] = useState(null); const [toursPopupOpen, setToursPopupOpen] = useState(false); + const [artistFilterModal, setArtistFilterModal] = useState<{ + movementId: number; + movementName: string; + artists: ArtistSummary[]; + } | null>(null); const effectiveDebugMode = debugMode && canImages; const [galleryRevision, setGalleryRevision] = useState(0); const viewRef = useRef(view); @@ -639,11 +647,43 @@ export default function HomePage() { }; const handleMovementClick = async (movementId: number) => { + setGalleryEntryLoading('Loading artists…'); + try { + const artists = await api.getMovementArtistsSummary(movementId); + if (artists.length === 0) { + setError('No artists in this movement.'); + return; + } + const movement = timelineData.movements.find((m) => m.id === movementId); + setArtistFilterModal({ + movementId, + movementName: movement?.name ?? 'Movement', + artists, + }); + } catch { + setError('Failed to load movement artists.'); + } finally { + setGalleryEntryLoading(null); + } + }; + + const handleArtistFilterProceed = async (selectedIds: Set) => { + if (!artistFilterModal) return; + const { movementId } = artistFilterModal; + setArtistFilterModal(null); setGalleryEntryLoading('Opening movement gallery…'); try { await api.preloadMovementImages(movementId).catch(() => undefined); const data = await api.getMovementGallery(movementId); - openMovementGallery(movementId, data); + const filtered: MovementGalleryDetail = { + ...data, + paintings: data.paintings.filter((p) => selectedIds.has(Number(p.artist_id))), + }; + if (filtered.paintings.length === 0) { + setError('No paintings for the selected artists.'); + return; + } + openMovementGallery(movementId, filtered); } catch { setError('Failed to load movement gallery.'); } finally { @@ -1117,6 +1157,17 @@ export default function HomePage() { onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)} /> + {artistFilterModal && ( + setArtistFilterModal(null)} + onProceed={(selectedIds) => void handleArtistFilterProceed(selectedIds)} + /> + )} + {view.type === 'timeline' && (
diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 7774f9f..16582a1 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -136,6 +136,19 @@ export interface MovementGalleryDetail { paintings: Painting[]; } +/** Artist row for movement gallery entry filter (portraits + painting counts). */ +export interface ArtistSummary { + id: number; + name: string; + birth_year: number | null; + death_year: number | null; + portrait_path: string | null; + portrait_thumb_path?: string | null; + portrait_cache_key?: number | null; + portrait_thumb_cache_key?: number | null; + painting_count: number; +} + export interface TourSummary { id: number; title: string; diff --git a/server/index.js b/server/index.js index 03f6f5c..0852d89 100644 --- a/server/index.js +++ b/server/index.js @@ -335,6 +335,29 @@ app.get('/api/movements/:id/gallery', async (req, res) => { } }); +// Artists for a movement with painting counts (for gallery entry filter modal) +app.get('/api/movements/:id/artists-summary', async (req, res) => { + try { + const { id } = req.params; + const result = await pool.query( + `SELECT a.id, a.name, a.birth_year, a.death_year, a.portrait_path, a.portrait_thumb_path, + COUNT(p.id)::int AS painting_count + FROM artists a + LEFT JOIN paintings p ON p.artist_id = a.id + WHERE a.movement_id = $1 + GROUP BY a.id + ORDER BY a.birth_year NULLS LAST, a.name`, + [id] + ); + const { locale, statuses } = localeContext(req); + const localized = await localizeArtists(result.rows, locale, statuses); + res.json(localized.map(enrichArtistRow)); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch movement artists summary' }); + } +}); + // Artists for a movement in a time range app.get('/api/movements/:id/artists', async (req, res) => { try {