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 <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-29 19:05:54 +03:00
co-authored by Cursor
parent ad1572b3aa
commit 0a5918c4dd
9 changed files with 589 additions and 23 deletions
+30 -1
View File
@@ -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**
+8 -8
View File
@@ -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 movements 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 `<Canvas>` 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.
+28
View File
@@ -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<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getMovementArtistsSummary: (id: number) =>
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
+229
View File
@@ -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;
}
}
+171
View File
@@ -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<number, number>;
onProceed: (selectedIds: Set<number>) => 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 (
<span className="artist-filter-portrait artist-filter-portrait-placeholder" aria-hidden>
?
</span>
);
}
return (
<img
src={queuedSrc}
alt=""
className="artist-filter-portrait"
onError={() => {
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<Set<number>>(() => 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 (
<div className="artist-filter-backdrop" onMouseDown={onClose}>
<div
className="artist-filter-modal"
role="dialog"
aria-labelledby="artist-filter-title"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<header className="artist-filter-header">
<h2 id="artist-filter-title">{movementName}</h2>
<p className="artist-filter-subtitle">Choose artists to include in the gallery hall</p>
</header>
<div className="artist-filter-toolbar">
<button type="button" className="artist-filter-toggle-all" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="artist-filter-count">
{selected.size} of {artists.length} selected
</span>
</div>
<div className="artist-filter-grid">
{artists.map((artist) => {
const isSelected = selected.has(artist.id);
return (
<button
key={artist.id}
type="button"
className={`artist-filter-card${isSelected ? ' artist-filter-card-selected' : ''}`}
onClick={() => toggleArtist(artist.id)}
aria-pressed={isSelected}
>
<span className="artist-filter-portrait-wrap">
<ArtistFilterPortrait
artist={artist}
revision={portraitRevisions?.[artist.id]}
/>
<span className="artist-filter-check" aria-hidden>
{isSelected ? '✓' : ''}
</span>
</span>
<span className="artist-filter-name">{artist.name}</span>
<span className="artist-filter-years">{formatLifespan(artist.birth_year, artist.death_year)}</span>
<span className="artist-filter-paintings">{paintingCountLabel(artist.painting_count)}</span>
</button>
);
})}
</div>
<footer className="artist-filter-footer">
<button type="button" className="artist-filter-cancel" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="artist-filter-proceed"
disabled={noneSelected}
onClick={() => onProceed(new Set(selected))}
>
Enter gallery
</button>
</footer>
</div>
</div>
);
}
+35 -13
View File
@@ -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<THREE.Texture | null>(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);
+52 -1
View File
@@ -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<CuratorLoginRedirect>(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<number>) => {
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 && (
<ArtistFilterModal
open
movementName={artistFilterModal.movementName}
artists={artistFilterModal.artists}
portraitRevisions={portraitRevisions}
onClose={() => setArtistFilterModal(null)}
onProceed={(selectedIds) => void handleArtistFilterProceed(selectedIds)}
/>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
+13
View File
@@ -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;
+23
View File
@@ -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 {