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:
co-authored by
Cursor
parent
ad1572b3aa
commit
0a5918c4dd
+30
-1
@@ -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,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 `<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.
|
||||
|
||||
|
||||
@@ -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`)),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user