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`
|
## `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**
|
**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.
|
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.
|
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.
|
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**).
|
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 |
|
| 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) |
|
| 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 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 |
|
| 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.
|
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) |
|
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
|
||||||
| Drag on flow canvas | Pan |
|
| Drag on flow canvas | Pan |
|
||||||
| Click portrait | Open artist biography |
|
| 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.
|
**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
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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
|
### 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.
|
**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,
|
Artist,
|
||||||
ArtistDetail,
|
ArtistDetail,
|
||||||
MovementGalleryDetail,
|
MovementGalleryDetail,
|
||||||
|
ArtistSummary,
|
||||||
Painting,
|
Painting,
|
||||||
PaintingDetail,
|
PaintingDetail,
|
||||||
ArtistNavigation,
|
ArtistNavigation,
|
||||||
@@ -176,6 +177,30 @@ export function galleryImageUrl(
|
|||||||
return null;
|
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(
|
export function galleryImageUrlWithRevision(
|
||||||
painting: {
|
painting: {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -458,6 +483,9 @@ export const api = {
|
|||||||
|
|
||||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||||
|
|
||||||
|
getMovementArtistsSummary: (id: number) =>
|
||||||
|
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
|
||||||
|
|
||||||
getArtistNavigation: (id: number) =>
|
getArtistNavigation: (id: number) =>
|
||||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
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,
|
MovementArtistGroup,
|
||||||
TourGalleryDetail,
|
TourGalleryDetail,
|
||||||
} from '../types';
|
} 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 { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
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 [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||||
const [failed, setFailed] = useState(!url);
|
const [failed, setFailed] = useState(candidates.length === 0);
|
||||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||||
const { gl } = useThree();
|
const { gl } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlIndex(0);
|
||||||
|
}, [candidateKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
setFailed(true);
|
setFailed(candidates.length === 0 || urlIndex >= candidates.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
setTexture(null);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let loaded: THREE.Texture | null = null;
|
let loaded: THREE.Texture | null = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -674,9 +686,12 @@ function usePaintingTexture(url: string | null) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
textureLoad?.begin();
|
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(() => {
|
const loadTimeout = window.setTimeout(() => {
|
||||||
if (settled || disposed) return;
|
if (settled || disposed) return;
|
||||||
setFailed(true);
|
|
||||||
finish();
|
finish();
|
||||||
}, TEXTURE_LOAD_TIMEOUT_MS);
|
}, TEXTURE_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
@@ -692,17 +707,21 @@ function usePaintingTexture(url: string | null) {
|
|||||||
const img = tex.image as HTMLImageElement | undefined;
|
const img = tex.image as HTMLImageElement | undefined;
|
||||||
if (!img || img.width < 4 || img.height < 4) {
|
if (!img || img.width < 4 || img.height < 4) {
|
||||||
tex.dispose();
|
tex.dispose();
|
||||||
setFailed(true);
|
|
||||||
finish();
|
finish();
|
||||||
|
if (!disposed) {
|
||||||
|
if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1);
|
||||||
|
else setFailed(true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loaded = tex;
|
loaded = tex;
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
tex.anisotropy = 4;
|
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();
|
finish();
|
||||||
if (!disposed) setTexture(tex);
|
if (!disposed) {
|
||||||
|
setFailed(false);
|
||||||
|
setTexture(tex);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!disposed) gl.initTexture(tex);
|
if (!disposed) gl.initTexture(tex);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -713,7 +732,10 @@ function usePaintingTexture(url: string | null) {
|
|||||||
() => {
|
() => {
|
||||||
window.clearTimeout(loadTimeout);
|
window.clearTimeout(loadTimeout);
|
||||||
finish();
|
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();
|
loaded?.dispose();
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
};
|
};
|
||||||
}, [url, textureLoad, gl]);
|
}, [url, urlIndex, candidates.length, textureLoad, gl]);
|
||||||
|
|
||||||
return { texture, failed };
|
return { texture, failed };
|
||||||
}
|
}
|
||||||
@@ -877,8 +899,8 @@ function PaintingFrame({
|
|||||||
const reviewed = paintingIsReviewed(painting);
|
const reviewed = paintingIsReviewed(painting);
|
||||||
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
||||||
const hasImage = paintingHasGalleryImage(painting);
|
const hasImage = paintingHasGalleryImage(painting);
|
||||||
const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
|
const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : [];
|
||||||
const { texture, failed } = usePaintingTexture(url);
|
const { texture, failed } = usePaintingTexture(urls);
|
||||||
const showImage = hasImage && !failed && !!texture;
|
const showImage = hasImage && !failed && !!texture;
|
||||||
const showCanvas = !showImage;
|
const showCanvas = !showImage;
|
||||||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import InfluencesPage from '../pages/InfluencesPage';
|
|||||||
import ToursPage from '../pages/ToursPage';
|
import ToursPage from '../pages/ToursPage';
|
||||||
import UsersPage from '../pages/UsersPage';
|
import UsersPage from '../pages/UsersPage';
|
||||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||||
|
import ArtistFilterModal from '../components/ArtistFilterModal';
|
||||||
import ToursPopup from '../components/ToursPopup';
|
import ToursPopup from '../components/ToursPopup';
|
||||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||||
import '../components/CatalogSearchBar.css';
|
import '../components/CatalogSearchBar.css';
|
||||||
import '../components/CuratorLoginModal.css';
|
import '../components/CuratorLoginModal.css';
|
||||||
|
import '../components/ArtistFilterModal.css';
|
||||||
import '../components/ToursPopup.css';
|
import '../components/ToursPopup.css';
|
||||||
import '../components/LocaleSwitcher.css';
|
import '../components/LocaleSwitcher.css';
|
||||||
import '../pages/TranslationsPage.css';
|
import '../pages/TranslationsPage.css';
|
||||||
@@ -34,6 +36,7 @@ import type {
|
|||||||
PaintingDetail,
|
PaintingDetail,
|
||||||
MovementGalleryDetail,
|
MovementGalleryDetail,
|
||||||
TourGalleryDetail,
|
TourGalleryDetail,
|
||||||
|
ArtistSummary,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
@@ -178,6 +181,11 @@ export default function HomePage() {
|
|||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
||||||
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
||||||
|
const [artistFilterModal, setArtistFilterModal] = useState<{
|
||||||
|
movementId: number;
|
||||||
|
movementName: string;
|
||||||
|
artists: ArtistSummary[];
|
||||||
|
} | null>(null);
|
||||||
const effectiveDebugMode = debugMode && canImages;
|
const effectiveDebugMode = debugMode && canImages;
|
||||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||||
const viewRef = useRef(view);
|
const viewRef = useRef(view);
|
||||||
@@ -639,11 +647,43 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMovementClick = async (movementId: number) => {
|
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…');
|
setGalleryEntryLoading('Opening movement gallery…');
|
||||||
try {
|
try {
|
||||||
await api.preloadMovementImages(movementId).catch(() => undefined);
|
await api.preloadMovementImages(movementId).catch(() => undefined);
|
||||||
const data = await api.getMovementGallery(movementId);
|
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 {
|
} catch {
|
||||||
setError('Failed to load movement gallery.');
|
setError('Failed to load movement gallery.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1117,6 +1157,17 @@ export default function HomePage() {
|
|||||||
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
|
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' && (
|
{view.type === 'timeline' && (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ export interface MovementGalleryDetail {
|
|||||||
paintings: Painting[];
|
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 {
|
export interface TourSummary {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
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
|
// Artists for a movement in a time range
|
||||||
app.get('/api/movements/:id/artists', async (req, res) => {
|
app.get('/api/movements/:id/artists', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user