From 08f99d7a29a51ab60352adcf5e3f7a549c6d28cb Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Fri, 19 Jun 2026 10:10:13 +0300 Subject: [PATCH] Redesign 3D gallery as single hall per artist with exit navigation. Restore React client source, add hall-to-hall navigation via painting influences grouped by movement, and update documentation. Co-authored-by: Cursor --- Documentation/API.md | 36 + Documentation/DB_structure.md | 2 + Documentation/basics.md | 29 +- Documentation/data-and-images.md | 2 + Documentation/setup.md | 1 + README.md | 2 +- client/public/placeholder-art.svg | 7 + client/public/placeholder-portrait.svg | 6 + client/src/App.tsx | 120 +--- client/src/api/client.ts | 69 ++ client/src/components/ArtistBio.css | 121 ++++ client/src/components/ArtistBio.tsx | 71 ++ client/src/components/MovementBands.css | 118 ++++ client/src/components/MovementBands.tsx | 119 ++++ client/src/components/PaintingDetail.css | 314 +++++++++ client/src/components/PaintingDetail.tsx | 149 +++++ client/src/components/Timeline.css | 129 ++++ client/src/components/Timeline.tsx | 192 ++++++ client/src/components/VirtualGallery.css | 364 ++++++++++ client/src/components/VirtualGallery.tsx | 803 +++++++++++++++++++++++ client/src/index.css | 126 +--- client/src/pages/HomePage.css | 43 ++ client/src/pages/HomePage.tsx | 159 +++++ client/src/types/index.ts | 120 ++++ server/index.js | 70 ++ 25 files changed, 2954 insertions(+), 218 deletions(-) create mode 100644 client/public/placeholder-art.svg create mode 100644 client/public/placeholder-portrait.svg create mode 100644 client/src/api/client.ts create mode 100644 client/src/components/ArtistBio.css create mode 100644 client/src/components/ArtistBio.tsx create mode 100644 client/src/components/MovementBands.css create mode 100644 client/src/components/MovementBands.tsx create mode 100644 client/src/components/PaintingDetail.css create mode 100644 client/src/components/PaintingDetail.tsx create mode 100644 client/src/components/Timeline.css create mode 100644 client/src/components/Timeline.tsx create mode 100644 client/src/components/VirtualGallery.css create mode 100644 client/src/components/VirtualGallery.tsx create mode 100644 client/src/pages/HomePage.css create mode 100644 client/src/pages/HomePage.tsx create mode 100644 client/src/types/index.ts diff --git a/Documentation/API.md b/Documentation/API.md index 56d48cd..d2371d7 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -92,6 +92,41 @@ Full artist profile for the bio page and 3D gallery entry. --- +## `GET /api/artists/:id/navigation` + +Related artists for hall-to-hall navigation at the exit doorway. Derived from `painting_influences`: + +- **Predecessors** — artists whose works influenced this artist’s paintings. +- **Successors** — artists whose works were influenced by this artist’s paintings. + +Both lists are grouped by art movement and exclude the current artist. + +**Response** + +```json +{ + "predecessors": [ + { + "movement_id": 4, + "movement_name": "Early Renaissance", + "movement_color": "#B8860B", + "artists": [ + { + "id": 12, + "name": "Domenico Ghirlandaio", + "birth_year": 1448, + "death_year": 1494, + "portrait_path": "portraits/Domenico_Ghirlandaio.jpg" + } + ] + } + ], + "successors": [] +} +``` + +--- + ## `POST /api/artists/:id/preload-images` Fast local scan: links paintings to files already on disk. Does **not** download from the internet (safe to call before opening the 3D gallery). @@ -146,6 +181,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`: | `api.getTimeline(start, end)` | `GET /api/timeline` | | `api.getArtists(...)` | `GET /api/artists` | | `api.getArtist(id)` | `GET /api/artists/:id` | +| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` | | `api.getPainting(id)` | `GET /api/paintings/:id` | | `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` | | `imageUrl(path)` | `/images/` or placeholder | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 39c4bc2..541348d 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -114,6 +114,8 @@ Directed edges: *this painting* was influenced by *that painting*. Unique constraint on `(painting_id, influenced_by_painting_id)`. +Used by the 3D gallery exit panel: predecessors are artists of `influenced_by_painting_id` works; successors are artists of paintings influenced by this artist’s works (see [API.md](API.md#get-apiartistsidnavigation)). + ## Indexes - `artists(movement_id)`, `artists(century)` diff --git a/Documentation/basics.md b/Documentation/basics.md index f822448..68da68d 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -8,7 +8,7 @@ The app is organised as a **drill-down hierarchy**: 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 2. **Movement bands** — art movements aligned to the same time axis, each showing portrait thumbnails of key artists. -3. **3D gallery** — classic hall environment; paintings grouped by the artist’s creative periods. +3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation. 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right, link to artist biography. All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing). @@ -63,19 +63,42 @@ Use the Vite URL during frontend work for HMR. flowchart TD A[Home — timeline + movements] -->|scroll / drag / zoom| A A -->|click portrait| B[Artist bio] - B -->|Enter Gallery| C[3D hall] + B -->|Enter Gallery| C[Artist hall — 3D] C -->|click painting| D[Painting detail + influences] + C -->|exit doorway / E key| E[Path picker] + E -->|predecessors| C + E -->|successors| C D -->|thumbnail left/right| D D -->|artist link| B B -->|Back| A C -->|Back| A ``` +## Virtual gallery (3D halls) + +Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog: + +| Rule | Implementation | +|------|----------------| +| One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings and periods | +| Paintings on walls | Works hang on the **back, left, and right** walls at eye level; periods are distributed across walls | +| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects | +| Single exit | One doorway on the **front wall**; walk to it or click it | +| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement | + +**Controls:** `WASD` / arrow keys to move; click a painting to open its detail view. At the exit, click the doorway or press `E` to choose the next artist. + +Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data. + +**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. + ## Key design decisions - **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins. - **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range. -- **3D gallery images** use locally cached files only (`galleryImageUrl`); slow remote fetches would break realtime rendering. +- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button. +- **Influence-based hall links** connect artists through documented painting-to-painting relationships, grouped by movement at the exit. +- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. - **Influence data** is stored as directed edges between paintings, with optional citation fields (source author, quote, URL) for art-historical references. ## Related docs diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index daec44c..96de440 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -34,6 +34,8 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco - Downloads painting images into `paintings/`. 3. Writes **painting_influences** edges from curated scholarship references. +Those influence edges also power **3D hall navigation**: predecessors and successors at each artist’s exit doorway are computed from this table (see `GET /api/artists/:id/navigation` in [API.md](API.md)). + Artists are grouped by movement and century; the seed list targets at most ~100 artists per century. ## On-demand image resolution diff --git a/Documentation/setup.md b/Documentation/setup.md index a71758c..dd83fd5 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -99,5 +99,6 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | Empty timeline | DB not seeded | `npm run seed` | | 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs | | Black frames in 3D gallery | No local image for painting | `POST …/preload-images` or `npm run fetch-images` | +| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` or extend seed data | | Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` | | Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate | diff --git a/README.md b/README.md index 4ffaab6..2156b76 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline, art movement bands, 3D navigable gallery halls, and painting influence graphs. +Interactive virtual art gallery: zoomable historical timeline, art movement bands, one 3D hall per artist (wall-mounted works, influence-linked exits), and painting detail views. ## Documentation diff --git a/client/public/placeholder-art.svg b/client/public/placeholder-art.svg new file mode 100644 index 0000000..f04b79c --- /dev/null +++ b/client/public/placeholder-art.svg @@ -0,0 +1,7 @@ + + + + + + Artwork + diff --git a/client/public/placeholder-portrait.svg b/client/public/placeholder-portrait.svg new file mode 100644 index 0000000..c6cf0b7 --- /dev/null +++ b/client/public/placeholder-portrait.svg @@ -0,0 +1,6 @@ + + + + + Portrait + diff --git a/client/src/App.tsx b/client/src/App.tsx index a66b5ef..fb098eb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,122 +1,8 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import HomePage from './pages/HomePage' +import './index.css' function App() { - const [count, setCount] = useState(0) - - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + return } export default App diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..65a926e --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,69 @@ +import type { + TimelineData, + YearBounds, + Artist, + ArtistDetail, + PaintingDetail, + ArtistNavigation, +} from '../types'; + +const API = '/api'; + +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export function imageUrl(path: string | null | undefined): string { + if (!path) return '/placeholder-art.svg'; + return `/images/${path}`; +} + +/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */ +export function galleryImageUrl(painting: { + thumbnail_path?: string | null; + image_path?: string | null; +}): string | null { + if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; + if (painting.image_path) return `/images/${painting.image_path}`; + return null; +} + +export function paintingImageUrl(painting: { + id: number; + image_path?: string | null; + thumbnail_path?: string | null; +}): string { + if (painting.image_path) return `/images/${painting.image_path}`; + if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; + return `/api/paintings/${painting.id}/image?size=full`; +} + +export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> { + const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' }); + if (!res.ok) throw new Error('Preload failed'); + return res.json(); +} + +export const api = { + getBounds: () => fetchJson(`${API}/bounds`), + + getTimeline: (start: number, end: number) => + fetchJson(`${API}/timeline?start=${start}&end=${end}`), + + getArtists: (start?: number, end?: number, movementId?: number) => { + const params = new URLSearchParams(); + if (start != null) params.set('start', String(start)); + if (end != null) params.set('end', String(end)); + if (movementId != null) params.set('movement_id', String(movementId)); + return fetchJson(`${API}/artists?${params}`); + }, + + getArtist: (id: number) => fetchJson(`${API}/artists/${id}`), + + getArtistNavigation: (id: number) => + fetchJson(`${API}/artists/${id}/navigation`), + + getPainting: (id: number) => fetchJson(`${API}/paintings/${id}`), +}; diff --git a/client/src/components/ArtistBio.css b/client/src/components/ArtistBio.css new file mode 100644 index 0000000..0d0ffa6 --- /dev/null +++ b/client/src/components/ArtistBio.css @@ -0,0 +1,121 @@ +.artist-bio { + min-height: 100vh; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + color: #e8d5b5; +} + +.bio-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 2px solid #c9a96e; +} + +.bio-header h1 { + font-family: 'Georgia', serif; + font-size: 28px; + margin: 0; + color: #e8d5b5; +} + +.back-btn, +.gallery-btn { + padding: 8px 16px; + border: 1px solid #c9a96e; + background: rgba(201, 169, 110, 0.15); + color: #e8d5b5; + border-radius: 4px; + cursor: pointer; + font-family: 'Georgia', serif; + font-size: 14px; + transition: background 0.2s; +} + +.back-btn:hover, +.gallery-btn:hover { + background: rgba(201, 169, 110, 0.35); +} + +.bio-content { + display: flex; + gap: 40px; + max-width: 900px; + margin: 40px auto; + padding: 0 24px; +} + +.bio-portrait { + flex-shrink: 0; +} + +.bio-portrait img { + width: 240px; + height: 300px; + object-fit: cover; + border: 4px solid #c9a96e; + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.bio-text { + flex: 1; +} + +.bio-meta { + display: flex; + gap: 16px; + margin-bottom: 16px; +} + +.bio-lifespan { + color: #c9a96e; + font-family: 'Georgia', serif; + font-size: 16px; +} + +.bio-movement { + padding: 2px 10px; + background: rgba(201, 169, 110, 0.15); + border: 1px solid rgba(201, 169, 110, 0.3); + border-radius: 12px; + font-size: 13px; + color: #c9a96e; +} + +.bio-summary { + font-family: 'Georgia', serif; + font-size: 18px; + line-height: 1.6; + color: #e8d5b5; + font-style: italic; + margin-bottom: 20px; +} + +.bio-full p { + font-family: 'Georgia', serif; + font-size: 15px; + line-height: 1.7; + color: rgba(232, 213, 181, 0.85); + margin-bottom: 12px; +} + +.bio-empty { + color: rgba(232, 213, 181, 0.5); + font-style: italic; +} + +.bio-source { + margin-top: 24px; + font-size: 12px; + color: rgba(201, 169, 110, 0.5); + border-top: 1px solid rgba(201, 169, 110, 0.2); + padding-top: 12px; +} + +@media (max-width: 768px) { + .bio-content { + flex-direction: column; + align-items: center; + } +} diff --git a/client/src/components/ArtistBio.tsx b/client/src/components/ArtistBio.tsx new file mode 100644 index 0000000..6bdb539 --- /dev/null +++ b/client/src/components/ArtistBio.tsx @@ -0,0 +1,71 @@ +import type { Artist } from '../types'; +import { imageUrl } from '../api/client'; +import './ArtistBio.css'; + +interface Props { + artist: Artist & { movement_name?: string }; + onBack: () => void; + onEnterGallery: () => void; +} + +export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) { + const lifespan = + artist.birth_year && artist.death_year + ? `${artist.birth_year} – ${artist.death_year}` + : artist.birth_year + ? `b. ${artist.birth_year}` + : ''; + + return ( +
+
+ +

{artist.name}

+ +
+ +
+
+ {artist.name} { + (e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; + }} + /> +
+ +
+
+ {lifespan && {lifespan}} + {artist.movement_name && ( + {artist.movement_name} + )} +
+ + {artist.bio_short && ( +

{artist.bio_short}

+ )} + + {artist.bio_full && ( +
+ {artist.bio_full.split('\n').map((para, i) => ( +

{para}

+ ))} +
+ )} + + {!artist.bio_full && !artist.bio_short && ( +

Biographical information not yet available.

+ )} + + {artist.wikipedia_title && ( +

+ Information sourced from Wikipedia article: {artist.wikipedia_title} +

+ )} +
+
+
+ ); +} diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css new file mode 100644 index 0000000..2705cc9 --- /dev/null +++ b/client/src/components/MovementBands.css @@ -0,0 +1,118 @@ +.movements-container { + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.movements-empty { + padding: 48px; + text-align: center; + color: rgba(201, 169, 110, 0.6); + font-family: 'Georgia', serif; +} + +.movement-row { + display: flex; + align-items: stretch; + min-height: 90px; +} + +.movement-label { + width: 160px; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding-right: 12px; + text-align: right; +} + +.movement-name { + font-family: 'Georgia', serif; + font-size: 13px; + font-weight: 600; + color: #e8d5b5; +} + +.movement-era { + font-size: 10px; + color: rgba(201, 169, 110, 0.5); + margin-top: 2px; +} + +.movement-band-track { + flex: 1; + position: relative; + min-height: 80px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border: 1px solid rgba(201, 169, 110, 0.1); +} + +.movement-band { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 3px; + min-width: 40px; +} + +.artist-portrait { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + background: none; + border: 2px solid rgba(201, 169, 110, 0.6); + border-radius: 50%; + width: 56px; + height: 56px; + padding: 0; + cursor: pointer; + overflow: visible; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; + z-index: 2; +} + +.artist-portrait:hover { + transform: translate(-50%, -50%) scale(1.15); + border-color: #c9a96e; + box-shadow: 0 4px 16px rgba(201, 169, 110, 0.4); + z-index: 10; +} + +.artist-portrait img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.artist-portrait .artist-name { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: 10px; + color: #e8d5b5; + margin-top: 4px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); +} + +.artist-portrait:hover .artist-name { + opacity: 1; +} + +@media (max-width: 768px) { + .movement-label { + width: 100px; + } + .artist-portrait { + width: 44px; + height: 44px; + } +} diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx new file mode 100644 index 0000000..12504ef --- /dev/null +++ b/client/src/components/MovementBands.tsx @@ -0,0 +1,119 @@ +import type { ArtMovement, Artist } from '../types'; +import { imageUrl } from '../api/client'; +import './MovementBands.css'; + +interface Props { + movements: ArtMovement[]; + artists: Artist[]; + viewStart: number; + viewEnd: number; + onArtistClick: (artistId: number) => void; +} + +function yearToPercent(year: number, start: number, end: number): number { + return ((year - start) / (end - start)) * 100; +} + +function artistInView(artist: Artist, viewStart: number, viewEnd: number): boolean { + const birth = artist.birth_year ?? viewStart; + const death = artist.death_year ?? viewEnd; + return death >= viewStart && birth <= viewEnd; +} + +function artistTimelineYear(artist: Artist): number { + if (artist.birth_year && artist.death_year) { + return Math.round((artist.birth_year + artist.death_year) / 2); + } + return artist.birth_year ?? artist.death_year ?? 0; +} + +export default function MovementBands({ movements, artists, viewStart, viewEnd, onArtistClick }: Props) { + const artistsByMovement = new Map(); + for (const artist of artists) { + if (!artist.movement_id || !artistInView(artist, viewStart, viewEnd)) continue; + const list = artistsByMovement.get(artist.movement_id) || []; + list.push(artist); + artistsByMovement.set(artist.movement_id, list); + } + + const visibleMovements = movements.filter( + (m) => m.end_year >= viewStart && m.start_year <= viewEnd && (artistsByMovement.get(m.id)?.length ?? 0) > 0 + ); + + if (visibleMovements.length === 0) { + return ( +
+

No art movements in this time range. Zoom out to explore more periods.

+
+ ); + } + + return ( +
+ {visibleMovements.map((movement) => { + const left = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd); + const right = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd); + const width = right - left; + if (width <= 0) return null; + + const movementArtists = artistsByMovement.get(movement.id) || []; + + return ( +
+
+ {movement.name} + {movement.era_name && {movement.era_name}} +
+
+
+ {movementArtists.map((artist) => { + const timelineYear = artistTimelineYear(artist); + const artistLeft = yearToPercent(timelineYear, viewStart, viewEnd); + if (artistLeft < left || artistLeft > right) return null; + + const relLeft = ((artistLeft - left) / width) * 100; + + return ( + + ); + })} +
+
+
+ ); + })} +
+ ); +} diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css new file mode 100644 index 0000000..f0d53ed --- /dev/null +++ b/client/src/components/PaintingDetail.css @@ -0,0 +1,314 @@ +.painting-detail { + min-height: 100vh; + background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%); + color: #e8d5b5; +} + +.painting-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 2px solid #c9a96e; + background: rgba(26, 26, 46, 0.95); + position: sticky; + top: 0; + z-index: 50; +} + +.painting-title-block h1 { + font-family: 'Georgia', serif; + font-size: 24px; + margin: 0; + color: #e8d5b5; +} + +.painting-meta { + margin: 4px 0 0; + color: #c9a96e; + font-size: 14px; +} + +.back-btn, +.bio-btn { + padding: 8px 16px; + border: 1px solid #c9a96e; + background: rgba(201, 169, 110, 0.15); + color: #e8d5b5; + border-radius: 4px; + cursor: pointer; + font-family: 'Georgia', serif; + font-size: 14px; + white-space: nowrap; + transition: background 0.2s; +} + +.back-btn:hover, +.bio-btn:hover { + background: rgba(201, 169, 110, 0.35); +} + +.painting-layout { + display: grid; + grid-template-columns: minmax(300px, 360px) 1fr minmax(300px, 360px); + gap: 0; + min-height: calc(100vh - 80px); +} + +.influence-panel { + padding: 20px 16px; + overflow-y: auto; + max-height: calc(100vh - 80px); +} + +.influence-left, +.influence-right { + min-width: 300px; + max-width: 360px; +} + +.influence-left { + border-right: 1px solid rgba(201, 169, 110, 0.2); + background: rgba(0, 0, 0, 0.2); +} + +.influence-right { + border-left: 1px solid rgba(201, 169, 110, 0.2); + background: rgba(0, 0, 0, 0.2); +} + +.influence-panel h3 { + font-family: 'Georgia', serif; + color: #c9a96e; + font-size: 16px; + margin: 0 0 16px; + text-align: center; + border-bottom: 1px solid rgba(201, 169, 110, 0.3); + padding-bottom: 8px; +} + +.no-influences { + font-size: 13px; + color: rgba(232, 213, 181, 0.5); + text-align: center; + font-style: italic; +} + +.influence-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.influence-card-expanded { + background: rgba(201, 169, 110, 0.08); + border: 1px solid rgba(201, 169, 110, 0.2); + border-radius: 8px; + overflow: hidden; +} + +.influence-image-btn { + display: block; + width: 100%; + padding: 0; + border: none; + background: #2a1f15; + cursor: pointer; +} + +.influence-image-btn img { + width: 100%; + height: 140px; + object-fit: cover; + display: block; + transition: opacity 0.2s; +} + +.influence-image-btn:hover img { + opacity: 0.85; +} + +.influence-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.influence-title-btn { + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + color: inherit; +} + +.influence-title-btn:hover strong { + color: #c9a96e; +} + +.influence-title-btn strong { + display: block; + font-size: 14px; + color: #e8d5b5; + transition: color 0.2s; +} + +.influence-title-btn span { + font-size: 11px; + color: #c9a96e; +} + +.influence-aspects { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.aspect-tag { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(201, 169, 110, 0.15); + border: 1px solid rgba(201, 169, 110, 0.3); + color: #c9a96e; + text-transform: capitalize; +} + +.influence-notes { + font-size: 12px; + color: rgba(232, 213, 181, 0.8); + margin: 0; + line-height: 1.55; +} + +.influence-quote { + margin: 0; + padding: 10px 12px; + border-left: 3px solid #c9a96e; + background: rgba(0, 0, 0, 0.2); + border-radius: 0 4px 4px 0; +} + +.influence-quote p { + margin: 0 0 6px; + font-family: 'Georgia', serif; + font-size: 12px; + font-style: italic; + color: rgba(232, 213, 181, 0.9); + line-height: 1.5; +} + +.influence-quote footer { + font-size: 11px; + color: rgba(201, 169, 110, 0.7); +} + +.influence-quote cite { + font-style: normal; +} + +.influence-source-link { + font-size: 11px; + color: #87b5d8; + text-decoration: none; + border-bottom: 1px dotted rgba(135, 181, 216, 0.4); + align-self: flex-start; +} + +.influence-source-link:hover { + color: #a8d4f5; + border-bottom-color: #a8d4f5; +} + +.influence-card { + display: flex; + gap: 10px; + padding: 8px; + background: rgba(201, 169, 110, 0.08); + border: 1px solid rgba(201, 169, 110, 0.2); + border-radius: 6px; + cursor: pointer; + text-align: left; + color: inherit; + transition: background 0.2s, border-color 0.2s; +} + +.influence-card:hover { + background: rgba(201, 169, 110, 0.2); + border-color: #c9a96e; +} + +.influence-card img { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.influence-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.influence-info strong { + font-size: 13px; + color: #e8d5b5; +} + +.influence-info span { + font-size: 11px; + color: #c9a96e; +} + +.painting-center { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; +} + +.painting-frame-large { + max-width: 700px; + width: 100%; + border: 8px solid #5c3d2e; + border-image: linear-gradient(135deg, #8B6914, #c9a96e, #8B6914) 1; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0 0 2px #c9a96e; + background: #2a1f15; + padding: 4px; +} + +.painting-frame-large img { + width: 100%; + display: block; +} + +.painting-description { + max-width: 700px; + margin-top: 20px; + padding: 16px; + background: rgba(201, 169, 110, 0.08); + border-radius: 6px; + border: 1px solid rgba(201, 169, 110, 0.15); +} + +.painting-description p { + margin: 0; + font-family: 'Georgia', serif; + font-size: 14px; + line-height: 1.7; + color: rgba(232, 213, 181, 0.85); +} + +@media (max-width: 1024px) { + .painting-layout { + grid-template-columns: 1fr; + } + .influence-panel { + max-height: none; + } +} diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx new file mode 100644 index 0000000..2537a61 --- /dev/null +++ b/client/src/components/PaintingDetail.tsx @@ -0,0 +1,149 @@ +import type { PaintingDetail } from '../types'; +import { paintingImageUrl } from '../api/client'; +import './PaintingDetail.css'; + +interface Props { + data: PaintingDetail; + onBack: () => void; + onPaintingClick: (paintingId: number) => void; + onArtistBio: () => void; +} + +function InfluenceCard({ + inf, + onPaintingClick, +}: { + inf: PaintingDetail['influencedBy'][0]; + onPaintingClick: (id: number) => void; +}) { + const aspects = inf.aspects + ? inf.aspects.split(',').map((a) => a.trim()).filter(Boolean) + : []; + + return ( +
+ + +
+ + + {aspects.length > 0 && ( +
+ {aspects.map((aspect) => ( + {aspect} + ))} +
+ )} + + {inf.notes &&

{inf.notes}

} + + {inf.quote && ( +
+

“{inf.quote}”

+ {(inf.source_author || inf.source) && ( +
+ — {inf.source_author} + {inf.source && , {inf.source}} +
+ )} +
+ )} + + {inf.source_url && ( + e.stopPropagation()} + > + Read source: {inf.source_author || inf.source || 'Reference'} + + )} +
+
+ ); +} + +export default function PaintingDetailView({ data, onBack, onPaintingClick, onArtistBio }: Props) { + const { painting, influencedBy, influenced } = data; + + return ( +
+
+ +
+

{painting.title}

+

+ {painting.artist_name} + {painting.year && ` · ${painting.year}`} +

+
+ +
+ +
+ + +
+
+ {painting.title} { + (e.target as HTMLImageElement).src = '/placeholder-art.svg'; + }} + /> +
+ {painting.description && ( +
+

{painting.description}

+
+ )} +
+ + +
+
+ ); +} diff --git a/client/src/components/Timeline.css b/client/src/components/Timeline.css new file mode 100644 index 0000000..47997cc --- /dev/null +++ b/client/src/components/Timeline.css @@ -0,0 +1,129 @@ +.timeline-wrapper { + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + border-bottom: 2px solid #c9a96e; + padding: 12px 16px 8px; + position: sticky; + top: 0; + z-index: 100; +} + +.timeline-controls { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.timeline-controls button { + width: 32px; + height: 32px; + border: 1px solid #c9a96e; + background: rgba(201, 169, 110, 0.15); + color: #e8d5b5; + border-radius: 4px; + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: background 0.2s; +} + +.timeline-controls button:hover { + background: rgba(201, 169, 110, 0.35); +} + +.timeline-range { + margin-left: 12px; + color: #c9a96e; + font-family: 'Georgia', serif; + font-size: 14px; + letter-spacing: 0.5px; +} + +.timeline-container { + position: relative; + height: 72px; + cursor: grab; + user-select: none; + border: 1px solid rgba(201, 169, 110, 0.3); + border-radius: 4px; + overflow: hidden; +} + +.timeline-container:active { + cursor: grabbing; +} + +.timeline-track { + position: absolute; + inset: 0; + display: flex; +} + +.era-block { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.era-label { + color: rgba(255, 255, 255, 0.85); + font-family: 'Georgia', serif; + font-size: 13px; + font-weight: 600; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); + white-space: nowrap; + pointer-events: none; +} + +.timeline-ticks { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 24px; +} + +.tick { + position: absolute; + bottom: 0; + transform: translateX(-50%); + border-left: 1px solid rgba(201, 169, 110, 0.4); + height: 8px; +} + +.tick span { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: rgba(201, 169, 110, 0.7); + white-space: nowrap; +} + +.brush-handle { + position: absolute; + top: 0; + bottom: 0; + width: 6px; + transform: translateX(-50%); + cursor: ew-resize; + background: rgba(201, 169, 110, 0.5); + opacity: 0; + transition: opacity 0.2s; +} + +.timeline-container:hover .brush-handle { + opacity: 1; +} + +.timeline-hint { + margin: 6px 0 0; + font-size: 11px; + color: rgba(201, 169, 110, 0.5); + text-align: center; +} diff --git a/client/src/components/Timeline.tsx b/client/src/components/Timeline.tsx new file mode 100644 index 0000000..7787799 --- /dev/null +++ b/client/src/components/Timeline.tsx @@ -0,0 +1,192 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import type { HistoricalEra } from '../types'; +import './Timeline.css'; + +interface Props { + eras: HistoricalEra[]; + viewStart: number; + viewEnd: number; + onViewChange: (start: number, end: number) => void; + absoluteMin: number; + absoluteMax: number; +} + +function yearToPercent(year: number, start: number, end: number): number { + return ((year - start) / (end - start)) * 100; +} + +function formatYear(year: number): string { + if (year < 0) return `${Math.abs(year)} BCE`; + return `${year} CE`; +} + +export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax }: Props) { + const containerRef = useRef(null); + const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null); + const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 }); + + const span = viewEnd - viewStart; + const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1; + const ticks: number[] = []; + const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval; + for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const ratio = (e.clientX - rect.left) / rect.width; + const centerYear = viewStart + ratio * span; + const factor = e.deltaY > 0 ? 1.15 : 0.85; + const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor)); + let newStart = centerYear - ratio * newSpan; + let newEnd = centerYear + (1 - ratio) * newSpan; + if (newStart < absoluteMin) { newEnd += absoluteMin - newStart; newStart = absoluteMin; } + if (newEnd > absoluteMax) { newStart -= newEnd - absoluteMax; newEnd = absoluteMax; } + onViewChange(Math.round(newStart), Math.round(newEnd)); + }, + [viewStart, span, absoluteMin, absoluteMax, onViewChange] + ); + + const handleMouseDown = (e: React.MouseEvent, mode: 'left' | 'right' | 'pan') => { + e.preventDefault(); + setDragging(mode); + dragStart.current = { x: e.clientX, viewStart, viewEnd }; + }; + + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const dx = e.clientX - dragStart.current.x; + const yearDelta = (dx / rect.width) * span; + + if (dragging === 'pan') { + let ns = dragStart.current.viewStart - yearDelta; + let ne = dragStart.current.viewEnd - yearDelta; + if (ns < absoluteMin) { ne += absoluteMin - ns; ns = absoluteMin; } + if (ne > absoluteMax) { ns -= ne - absoluteMax; ne = absoluteMax; } + onViewChange(Math.round(ns), Math.round(ne)); + } else if (dragging === 'left') { + const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta); + onViewChange(Math.round(ns), viewEnd); + } else { + const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta); + onViewChange(viewStart, Math.round(ne)); + } + }; + const onUp = () => setDragging(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + }, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]); + + const zoomIn = () => { + const center = (viewStart + viewEnd) / 2; + const newSpan = Math.max(10, span * 0.5); + onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2)); + }; + + const zoomOut = () => { + const center = (viewStart + viewEnd) / 2; + const newSpan = Math.min(absoluteMax - absoluteMin, span * 2); + let ns = center - newSpan / 2; + let ne = center + newSpan / 2; + if (ns < absoluteMin) { ne += absoluteMin - ns; ns = absoluteMin; } + if (ne > absoluteMax) { ns -= ne - absoluteMax; ne = absoluteMax; } + onViewChange(Math.round(ns), Math.round(ne)); + }; + + const resetView = () => onViewChange(absoluteMin, absoluteMax); + + return ( +
+
+ + + + + {formatYear(viewStart)} — {formatYear(viewEnd)} + +
+ +
handleMouseDown(e, 'pan')} + > +
+ {eras.map((era) => { + const left = yearToPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd); + const right = yearToPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd); + if (right <= 0 || left >= 100) return null; + return ( +
+ {era.name} +
+ ); + })} +
+ +
+ {ticks.map((year) => ( +
+ {formatYear(year)} +
+ ))} +
+ +
{ e.stopPropagation(); handleMouseDown(e, 'left'); }} + /> +
{ e.stopPropagation(); handleMouseDown(e, 'right'); }} + /> +
+ +

+ Scroll to zoom · Drag to pan · Use +/− buttons to zoom into a historical period +

+
+ ); +} + +function getEraColor(name: string): string { + const colors: Record = { + 'Ancient': 'rgba(139,115,85,0.7)', + 'Medieval': 'rgba(74,85,104,0.7)', + 'Renaissance': 'rgba(184,134,11,0.7)', + 'Baroque': 'rgba(139,0,0,0.6)', + 'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)', + 'Modern': 'rgba(100,100,120,0.6)', + 'Contemporary': 'rgba(60,60,80,0.7)', + }; + return colors[name] || 'rgba(100,100,100,0.5)'; +} diff --git a/client/src/components/VirtualGallery.css b/client/src/components/VirtualGallery.css new file mode 100644 index 0000000..d440c46 --- /dev/null +++ b/client/src/components/VirtualGallery.css @@ -0,0 +1,364 @@ +.virtual-gallery { + display: flex; + flex-direction: column; + height: 100vh; + background: #1a1008; +} + +.gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: linear-gradient(180deg, #2a1f15 0%, #1a1008 100%); + border-bottom: 2px solid #c9a96e; +} + +.gallery-title-block { + flex: 1; + text-align: center; + min-width: 0; +} + +.gallery-header h2 { + font-family: 'Georgia', serif; + color: #e8d5b5; + margin: 0; + font-size: 22px; +} + +.gallery-career-path { + margin: 4px 0 0; + font-size: 12px; + color: rgba(201, 169, 110, 0.75); + font-family: 'Georgia', serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-header-meta { + display: flex; + align-items: center; + gap: 12px; +} + +.gallery-work-count { + font-size: 13px; + color: rgba(232, 213, 181, 0.65); + font-family: 'Georgia', serif; +} + +.gallery-back-btn, +.gallery-bio-btn { + padding: 8px 16px; + border: 1px solid #c9a96e; + background: rgba(201, 169, 110, 0.15); + color: #e8d5b5; + border-radius: 4px; + cursor: pointer; + font-family: 'Georgia', serif; + font-size: 14px; + transition: background 0.2s; +} + +.gallery-back-btn:hover, +.gallery-bio-btn:hover { + background: rgba(201, 169, 110, 0.35); +} + +.gallery-canvas-container { + flex: 1; + min-height: 0; + position: relative; +} + +.gallery-loading-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 9, 6, 0.85); + color: #e8d5b5; + font-family: Georgia, serif; + font-size: 16px; +} + +.gallery-sync-badge { + inset: auto; + top: 12px; + right: 12px; + left: auto; + bottom: auto; + padding: 8px 14px; + border-radius: 6px; + background: rgba(13, 9, 6, 0.75); + border: 1px solid rgba(201, 169, 110, 0.35); + font-size: 13px; + pointer-events: none; +} + +.gallery-controls { + display: flex; + gap: 24px; + padding: 12px 20px; + background: #2a1f15; + border-top: 1px solid rgba(201, 169, 110, 0.3); +} + +.control-pad { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.control-pad button { + width: 44px; + height: 44px; + border: 1px solid #c9a96e; + background: rgba(201, 169, 110, 0.15); + color: #e8d5b5; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background 0.2s; +} + +.control-pad button:hover { + background: rgba(201, 169, 110, 0.35); +} + +.control-row { + display: flex; + gap: 4px; +} + +.gallery-instructions { + flex: 1; +} + +.gallery-instructions h4 { + margin: 0 0 6px; + color: #c9a96e; + font-family: 'Georgia', serif; + font-size: 14px; +} + +.gallery-instructions ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px 20px; +} + +.gallery-instructions li { + font-size: 12px; + color: rgba(232, 213, 181, 0.7); +} + +.gallery-instructions kbd { + display: inline-block; + padding: 1px 6px; + border: 1px solid rgba(201, 169, 110, 0.4); + border-radius: 3px; + background: rgba(0, 0, 0, 0.3); + font-size: 11px; + color: #c9a96e; +} + +.gallery-exit-hint { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + padding: 10px 18px; + border-radius: 6px; + background: rgba(13, 9, 6, 0.82); + border: 1px solid rgba(212, 175, 55, 0.55); + color: #f5e6c8; + font-family: Georgia, serif; + font-size: 14px; + pointer-events: none; +} + +.gallery-exit-hint kbd { + padding: 1px 6px; + border: 1px solid rgba(201, 169, 110, 0.5); + border-radius: 3px; + background: rgba(0, 0, 0, 0.35); + font-size: 12px; +} + +.exit-nav-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(8, 5, 3, 0.72); + padding: 24px; +} + +.exit-nav-panel { + width: min(960px, 100%); + max-height: min(85vh, 720px); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #2a1f15 0%, #1a1008 100%); + border: 2px solid #c9a96e; + border-radius: 8px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); + overflow: hidden; +} + +.exit-nav-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(201, 169, 110, 0.35); +} + +.exit-nav-header h2 { + margin: 0; + font-family: Georgia, serif; + color: #e8d5b5; + font-size: 20px; + font-weight: normal; +} + +.exit-nav-close { + width: 36px; + height: 36px; + border: 1px solid rgba(201, 169, 110, 0.45); + border-radius: 4px; + background: transparent; + color: #e8d5b5; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.exit-nav-close:hover { + background: rgba(201, 169, 110, 0.2); +} + +.exit-nav-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + overflow: auto; + flex: 1; + min-height: 0; +} + +.exit-nav-column { + padding: 16px 20px 24px; + overflow-y: auto; +} + +.exit-nav-column:first-child { + border-right: 1px solid rgba(201, 169, 110, 0.25); +} + +.exit-nav-column h3 { + margin: 0 0 14px; + font-family: Georgia, serif; + color: #c9a96e; + font-size: 16px; + font-weight: normal; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.exit-nav-movement { + margin-bottom: 18px; +} + +.exit-nav-movement h4 { + margin: 0 0 8px; + padding-left: 10px; + border-left: 3px solid #8b7355; + font-family: Georgia, serif; + color: #e8d5b5; + font-size: 14px; + font-weight: normal; +} + +.exit-nav-movement ul { + list-style: none; + margin: 0; + padding: 0; +} + +.exit-nav-movement li + li { + margin-top: 6px; +} + +.exit-nav-movement button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 4px; + background: rgba(0, 0, 0, 0.2); + color: #e8d5b5; + font-family: Georgia, serif; + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.exit-nav-movement button:hover { + background: rgba(201, 169, 110, 0.18); + border-color: rgba(201, 169, 110, 0.4); +} + +.exit-nav-movement img { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + background: #3d2b1f; + flex-shrink: 0; +} + +.exit-nav-movement span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.exit-nav-movement small { + font-size: 11px; + color: rgba(232, 213, 181, 0.6); +} + +.exit-nav-empty, +.exit-nav-loading { + margin: 0; + font-size: 13px; + color: rgba(232, 213, 181, 0.55); + font-style: italic; +} + +@media (max-width: 640px) { + .exit-nav-columns { + grid-template-columns: 1fr; + } + + .exit-nav-column:first-child { + border-right: none; + border-bottom: 1px solid rgba(201, 169, 110, 0.25); + } +} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx new file mode 100644 index 0000000..3d37b95 --- /dev/null +++ b/client/src/components/VirtualGallery.tsx @@ -0,0 +1,803 @@ +import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { Text, Environment } from '@react-three/drei'; +import * as THREE from 'three'; +import type { + ArtistDetail, + Painting, + ArtistPeriod, + ArtistNavigation, + MovementArtistGroup, +} from '../types'; +import { galleryImageUrl, imageUrl, api, preloadArtistImages } from '../api/client'; +import './VirtualGallery.css'; + +interface Props { + data: ArtistDetail; + onPaintingClick: (paintingId: number) => void; + onNavigateArtist: (artistId: number) => void; + onBack: () => void; + onBioClick: () => void; +} + +const WALL_HEIGHT = 4.2; +const WALL_THICKNESS = 0.18; +const MOUNT_OFFSET = 0.06; +const EYE_HEIGHT = 1.65; +const HANG_HEIGHT = 1.55; +const FRAME_GAP = 0.18; +const MIN_FRAME_W = 0.45; +const MAX_FRAME_W = 1.05; +const MAX_FRAME_H = 1.35; +const MIN_HALL_SIZE = 9; +const MAX_FRAMES_PER_WALL = 10; +const DOOR_WIDTH = 2.4; +const DOOR_HEIGHT = 2.5; + +type WallSide = 'back' | 'left' | 'right'; + +interface FrameSlot { + position: [number, number, number]; + rotationY: number; + maxW: number; + maxH: number; +} + +interface WallSegment { + side: WallSide; + label: string; + paintings: Painting[]; + slots: FrameSlot[]; +} + +interface HallLayout { + width: number; + depth: number; + segments: WallSegment[]; +} + +function layoutRow(count: number, span: number) { + const gap = FRAME_GAP; + const padding = 1.4; + const available = span - padding; + let frameW = Math.min(MAX_FRAME_W, (available - (count - 1) * gap) / Math.max(count, 1)); + frameW = Math.max(MIN_FRAME_W, frameW); + const frameH = Math.min(MAX_FRAME_H, frameW * 1.22); + const rowWidth = count * frameW + (count - 1) * gap; + const slots: { offset: number; maxW: number; maxH: number }[] = []; + + for (let i = 0; i < count; i++) { + slots.push({ + offset: -rowWidth / 2 + frameW / 2 + i * (frameW + gap), + maxW: frameW, + maxH: frameH, + }); + } + + return { slots, spanNeeded: Math.max(span, rowWidth + padding) }; +} + +function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout { + const byPeriod = new Map(); + for (const p of paintings) { + const key = p.period_id || 0; + const list = byPeriod.get(key) || []; + list.push(p); + byPeriod.set(key, list); + } + + const periodGroups: { label: string; paintings: Painting[] }[] = []; + for (const period of periods) { + const list = byPeriod.get(period.id) || []; + if (list.length > 0) { + periodGroups.push({ + label: period.name, + paintings: [...list].sort((a, b) => (a.year || 0) - (b.year || 0)), + }); + } + } + const other = byPeriod.get(0) || []; + if (other.length > 0) { + periodGroups.push({ + label: 'Other Works', + paintings: [...other].sort((a, b) => (a.year || 0) - (b.year || 0)), + }); + } + + if (periodGroups.length === 0 && paintings.length > 0) { + periodGroups.push({ + label: 'Works', + paintings: [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0)), + }); + } + + const walls: WallSide[] = ['back', 'left', 'right']; + const wallBuckets: { label: string; paintings: Painting[] }[][] = [[], [], []]; + + periodGroups.forEach((group, i) => { + wallBuckets[i % 3].push(group); + }); + + let width = MIN_HALL_SIZE; + let depth = MIN_HALL_SIZE; + const segments: WallSegment[] = []; + + const addWallFrames = (side: WallSide, groups: { label: string; paintings: Painting[] }[]) => { + if (groups.length === 0) return; + + const flat: Painting[] = []; + const labels: string[] = []; + for (const g of groups) { + for (const p of g.paintings) { + if (flat.length >= MAX_FRAMES_PER_WALL) break; + flat.push(p); + } + if (flat.length <= MAX_FRAMES_PER_WALL) labels.push(g.label); + } + + const label = labels.join(' · '); + const span = side === 'back' ? width : depth; + const { spanNeeded } = layoutRow(flat.length, span); + + if (side === 'back') depth = Math.max(depth, spanNeeded); + else width = Math.max(width, spanNeeded); + + segments.push({ side, label, paintings: flat, slots: [] }); + }; + + walls.forEach((side, i) => addWallFrames(side, wallBuckets[i])); + + width = Math.max(width, MIN_HALL_SIZE); + depth = Math.max(depth, MIN_HALL_SIZE); + + const halfW = width / 2; + const halfD = depth / 2; + const y = HANG_HEIGHT; + const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; + + for (const seg of segments) { + const row = layoutRow(seg.paintings.length, seg.side === 'back' ? width : depth); + + seg.slots = row.slots.map((s) => { + if (seg.side === 'back') { + return { + maxW: s.maxW, + maxH: s.maxH, + rotationY: 0, + position: [s.offset, y, -halfD + inset], + }; + } + if (seg.side === 'left') { + return { + maxW: s.maxW, + maxH: s.maxH, + rotationY: Math.PI / 2, + position: [-halfW + inset, y, s.offset], + }; + } + return { + maxW: s.maxW, + maxH: s.maxH, + rotationY: -Math.PI / 2, + position: [halfW - inset, y, s.offset], + }; + }); + } + + return { width, depth, segments }; +} + +function computeFrameSize(aspect: number, maxW: number, maxH: number) { + let w = maxW; + let h = w / aspect; + if (h > maxH) { + h = maxH; + w = h * aspect; + } + return { width: w, height: h }; +} + +function usePaintingTexture(url: string | null) { + const [texture, setTexture] = useState(null); + const [failed, setFailed] = useState(!url); + + useEffect(() => { + if (!url) { + setTexture(null); + setFailed(true); + return; + } + + setFailed(false); + let disposed = false; + let loaded: THREE.Texture | null = null; + const loader = new THREE.TextureLoader(); + loader.setCrossOrigin('anonymous'); + loader.load( + url, + (tex) => { + if (disposed) { + tex.dispose(); + return; + } + loaded = tex; + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 4; + setTexture(tex); + }, + undefined, + () => { + if (!disposed) setFailed(true); + } + ); + + return () => { + disposed = true; + loaded?.dispose(); + setTexture(null); + }; + }, [url]); + + return { texture, failed }; +} + +function PaintingFrame({ + painting, + position, + rotationY, + maxWidth, + maxHeight, + onClick, +}: { + painting: Painting; + position: [number, number, number]; + rotationY: number; + maxWidth: number; + maxHeight: number; + onClick: () => void; +}) { + const [hovered, setHovered] = useState(false); + const [aspect, setAspect] = useState(1.33); + const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight); + const frameDepth = 0.06; + const matBorder = 0.05; + const url = galleryImageUrl(painting); + const { texture, failed } = usePaintingTexture(url); + + useEffect(() => { + const img = texture?.image as HTMLImageElement | undefined; + if (img?.width && img.height) { + setAspect(img.width / img.height); + } + }, [texture]); + + return ( + + + + { + e.stopPropagation(); + onClick(); + }} + onPointerOver={() => setHovered(true)} + onPointerOut={() => setHovered(false)} + > + + + + + + + + + + + + + + + ); +} + +function GalleryWall({ + position, + size, + rotation = [0, 0, 0], + color = '#ebe4d8', +}: { + position: [number, number, number]; + size: [number, number]; + rotation?: [number, number, number]; + color?: string; +}) { + const [w, h] = size; + return ( + + + + + ); +} + +function ExitPortal({ + position, + active, + onActivate, +}: { + position: [number, number, number]; + active: boolean; + onActivate: () => void; +}) { + const [hovered, setHovered] = useState(false); + const glow = active || hovered; + + return ( + + { + e.stopPropagation(); + onActivate(); + }} + onPointerOver={() => setHovered(true)} + onPointerOut={() => setHovered(false)} + > + + + + + EXIT + + + ); +} + +function ArtistHall({ + layout, + artistName, + onPaintingClick, + onExitActivate, + nearExit, +}: { + layout: HallLayout; + artistName: string; + onPaintingClick: (id: number) => void; + onExitActivate: () => void; + nearExit: boolean; +}) { + const { width, depth, segments } = layout; + const halfW = width / 2; + const halfD = depth / 2; + + return ( + + {/* Floor & ceiling — open centre, no furniture */} + + + + + + + + + + {/* Back wall */} + + {/* Left wall */} + + {/* Right wall */} + + + {/* Front wall — two segments with door gap */} + + + + {/* Crown molding on back wall */} + + + + + + + {artistName.toUpperCase()} + + + {segments.map((seg) => ( + + {seg.paintings.map((painting, i) => ( + onPaintingClick(painting.id)} + /> + ))} + {seg.label && ( + + {seg.label} + + )} + + ))} + + + + + + + ); +} + +function CameraController({ + position, + target, +}: { + position: THREE.Vector3; + target: THREE.Vector3; +}) { + const { camera } = useThree(); + useFrame(() => { + camera.position.lerp(position, 0.12); + camera.lookAt(target); + }); + return null; +} + +function NavigationPanel({ + navigation, + loading, + onSelect, + onClose, +}: { + navigation: ArtistNavigation | null; + loading: boolean; + onSelect: (artistId: number) => void; + onClose: () => void; +}) { + const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => ( +
+

{title}

+ {loading &&

Loading…

} + {!loading && groups.length === 0 &&

{emptyHint}

} + {!loading && + groups.map((group) => ( +
+

{group.movement_name}

+
    + {group.artists.map((artist) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ); + + return ( +
+
+
+

Choose your path

+ +
+
+ {renderColumn( + 'Predecessors', + navigation?.predecessors ?? [], + 'No documented predecessors via painting influences.' + )} + {renderColumn( + 'Successors', + navigation?.successors ?? [], + 'No documented successors via painting influences.' + )} +
+
+
+ ); +} + +export default function VirtualGallery({ + data, + onPaintingClick, + onNavigateArtist, + onBack, + onBioClick, +}: Props) { + const [artist, setArtist] = useState(data.artist); + const [periods, setPeriods] = useState(data.periods); + const [paintings, setPaintings] = useState(data.paintings); + const [syncStatus, setSyncStatus] = useState(''); + const [showExitNav, setShowExitNav] = useState(false); + const [navigation, setNavigation] = useState(null); + const [navLoading, setNavLoading] = useState(false); + const [nearExit, setNearExit] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + setSyncStatus('Syncing images…'); + const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); + await Promise.race([preloadArtistImages(data.artist.id), timeout]); + const fresh = await api.getArtist(data.artist.id); + if (!cancelled) { + setArtist(fresh.artist); + setPeriods(fresh.periods); + setPaintings(fresh.paintings); + const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length; + setSyncStatus( + withImg < fresh.paintings.length + ? `${withImg} of ${fresh.paintings.length} works have images` + : '' + ); + } + } catch { + if (!cancelled) setSyncStatus(''); + } + })(); + return () => { + cancelled = true; + }; + }, [data.artist.id]); + + const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]); + + const halfW = layout.width / 2 - 0.8; + const halfD = layout.depth / 2 - 0.8; + const exitZ = layout.depth / 2 - 0.6; + + const initialPos = useMemo( + () => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2), + [layout.depth] + ); + const initialTarget = useMemo( + () => new THREE.Vector3(0, HANG_HEIGHT, -layout.depth / 4), + [layout.depth] + ); + + const [camPos, setCamPos] = useState(() => initialPos.clone()); + const [camTarget, setCamTarget] = useState(() => initialTarget.clone()); + const keysPressed = useRef>(new Set()); + const camPosRef = useRef(camPos); + const camTargetRef = useRef(camTarget); + camPosRef.current = camPos; + camTargetRef.current = camTarget; + + useEffect(() => { + setCamPos(initialPos.clone()); + setCamTarget(initialTarget.clone()); + setShowExitNav(false); + setNearExit(false); + }, [data.artist.id, initialPos, initialTarget]); + + const openExitNav = useCallback(async () => { + setShowExitNav(true); + setNavLoading(true); + try { + const nav = await api.getArtistNavigation(artist.id); + setNavigation(nav); + } catch { + setNavigation({ predecessors: [], successors: [] }); + } finally { + setNavLoading(false); + } + }, [artist.id]); + + const moveCamera = useCallback( + (forward: number, strafe: number, rotY: number) => { + const pos = camPosRef.current.clone(); + const target = camTargetRef.current.clone(); + const angle = Math.atan2(target.x - pos.x, target.z - pos.z); + + if (rotY !== 0) { + const newAngle = angle + rotY; + const dist = pos.distanceTo(target); + target.x = pos.x + Math.sin(newAngle) * dist; + target.z = pos.z + Math.cos(newAngle) * dist; + } else { + const newAngle = angle; + pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; + pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; + target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; + target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; + } + + pos.x = Math.max(-halfW, Math.min(halfW, pos.x)); + target.x = Math.max(-halfW, Math.min(halfW, target.x)); + pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); + target.z = Math.max(-halfD, Math.min(exitZ, target.z)); + + setCamPos(pos); + setCamTarget(target); + + const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; + setNearExit(atExit); + }, + [halfW, halfD, exitZ] + ); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + keysPressed.current.add(e.key); + if ((e.key === 'e' || e.key === 'E') && nearExit && !showExitNav) { + openExitNav(); + } + }; + const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + const interval = setInterval(() => { + const keys = keysPressed.current; + if (keys.has('ArrowUp') || keys.has('w')) moveCamera(-0.1, 0, 0); + if (keys.has('ArrowDown') || keys.has('s')) moveCamera(0.1, 0, 0); + if (keys.has('ArrowLeft') || keys.has('a')) moveCamera(0, -0.08, 0); + if (keys.has('ArrowRight') || keys.has('d')) moveCamera(0, 0.08, 0); + }, 16); + + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + clearInterval(interval); + }; + }, [moveCamera, nearExit, showExitNav, openExitNav]); + + const handleNavigate = (artistId: number) => { + setShowExitNav(false); + onNavigateArtist(artistId); + }; + + return ( +
+
+ +
+

{artist.name}

+

Personal hall · {paintings.length} works on the walls

+
+
+ +
+
+ +
+ {syncStatus && ( +
+

{syncStatus}

+
+ )} + {nearExit && !showExitNav && ( +
At the exit — click the doorway or press E
+ )} + + + + + + + + + + + +
+ + {showExitNav && ( + setShowExitNav(false)} + /> + )} + +
+
+ +
+ + + +
+
+
+

{artist.name}'s Hall

+
    +
  • W / Walk into the room
  • +
  • S / Walk toward the exit
  • +
  • A / D Step sideways along the walls
  • +
  • Paintings hang on three walls — the centre stays open
  • +
  • Click a painting for details, or use the exit to visit related artists
  • +
+
+
+
+ ); +} diff --git a/client/src/index.css b/client/src/index.css index 5fb3313..42cc713 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,111 +1,43 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +*, +*::before, +*::after { box-sizing: border-box; } +html, body, #root { + margin: 0; + padding: 0; + min-height: 100vh; +} + body { - margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0f0f1a; + color: #e8d5b5; + -webkit-font-smoothing: antialiased; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +button { + font-family: inherit; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; +img { + max-width: 100%; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +::-webkit-scrollbar-thumb { + background: rgba(201, 169, 110, 0.3); border-radius: 4px; - color: var(--text-h); } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +::-webkit-scrollbar-thumb:hover { + background: rgba(201, 169, 110, 0.5); } diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css new file mode 100644 index 0000000..98bb569 --- /dev/null +++ b/client/src/pages/HomePage.css @@ -0,0 +1,43 @@ +.home-page { + min-height: 100vh; + background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%); +} + +.site-header { + text-align: center; + padding: 24px 16px 8px; +} + +.site-header h1 { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 32px; + color: #e8d5b5; + margin: 0; + letter-spacing: 2px; +} + +.site-subtitle { + color: rgba(201, 169, 110, 0.6); + font-family: 'Georgia', serif; + font-size: 14px; + margin: 8px 0 0; + font-style: italic; +} + +.loading { + text-align: center; + padding: 48px; + color: rgba(201, 169, 110, 0.6); + font-family: 'Georgia', serif; + font-size: 16px; +} + +.error-banner { + margin: 16px; + padding: 12px 16px; + background: rgba(139, 0, 0, 0.3); + border: 1px solid rgba(255, 100, 100, 0.4); + border-radius: 6px; + color: #ffaaaa; + text-align: center; +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx new file mode 100644 index 0000000..562c533 --- /dev/null +++ b/client/src/pages/HomePage.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect, useCallback } from 'react'; +import Timeline from '../components/Timeline'; +import MovementBands from '../components/MovementBands'; +import VirtualGallery from '../components/VirtualGallery'; +import PaintingDetailView from '../components/PaintingDetail'; +import ArtistBio from '../components/ArtistBio'; +import { api } from '../api/client'; +import type { TimelineData, Artist, ArtistDetail, PaintingDetail } from '../types'; +import './HomePage.css'; + +type View = + | { type: 'timeline' } + | { type: 'gallery'; artistId: number; data: ArtistDetail } + | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } + | { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View }; + +export default function HomePage() { + const [view, setView] = useState({ type: 'timeline' }); + const [bounds, setBounds] = useState({ min: -800, max: 2025 }); + const [viewStart, setViewStart] = useState(-800); + const [viewEnd, setViewEnd] = useState(2025); + const [timelineData, setTimelineData] = useState({ eras: [], movements: [] }); + const [artists, setArtists] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + api.getBounds() + .then((b) => { + const min = b.min_year ?? -800; + const max = b.max_year ?? 2025; + setBounds({ min, max }); + setViewStart(min); + setViewEnd(max); + }) + .catch(() => {}); + }, []); + + const loadTimelineData = useCallback(async (start: number, end: number) => { + try { + setLoading(true); + const [timeline, artistList] = await Promise.all([ + api.getTimeline(start, end), + api.getArtists(start, end), + ]); + setTimelineData(timeline); + setArtists(artistList); + setError(null); + } catch { + setError('Could not load gallery data. Is the server running?'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadTimelineData(viewStart, viewEnd); + }, [viewStart, viewEnd, loadTimelineData]); + + const handleViewChange = (start: number, end: number) => { + setViewStart(start); + setViewEnd(end); + }; + + const handleArtistClick = async (artistId: number) => { + try { + const data = await api.getArtist(artistId); + setView({ type: 'gallery', artistId, data }); + } catch { + setError('Failed to load artist gallery.'); + } + }; + + const handlePaintingClick = async (paintingId: number) => { + try { + const data = await api.getPainting(paintingId); + setView((prev) => ({ + type: 'painting', + paintingId, + data, + returnTo: prev, + })); + } catch { + setError('Failed to load painting details.'); + } + }; + + const handleBioClick = (artistData: ArtistDetail, returnTo: View) => { + setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo }); + }; + + if (view.type === 'gallery') { + return ( + setView({ type: 'timeline' })} + onBioClick={() => handleBioClick(view.data, view)} + /> + ); + } + + if (view.type === 'painting') { + return ( + setView(view.returnTo)} + onPaintingClick={handlePaintingClick} + onArtistBio={async () => { + const artistData = await api.getArtist(view.data.painting.artist_id); + setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view }); + }} + /> + ); + } + + if (view.type === 'bio') { + return ( + setView(view.returnTo)} + onEnterGallery={() => setView({ type: 'gallery', artistId: view.artistId, data: view.data })} + /> + ); + } + + return ( +
+
+

Virtual Art Gallery

+

Explore the flowing connections of art history

+
+ + + + {error &&
{error}
} + + {loading ? ( +
Loading art history...
+ ) : ( + + )} +
+ ); +} diff --git a/client/src/types/index.ts b/client/src/types/index.ts new file mode 100644 index 0000000..ccf3ac6 --- /dev/null +++ b/client/src/types/index.ts @@ -0,0 +1,120 @@ +export interface HistoricalEra { + id: number; + name: string; + start_year: number; + end_year: number; + start_definite: boolean; + end_definite: boolean; + description: string; + sort_order: number; +} + +export interface ArtMovement { + id: number; + name: string; + start_year: number; + end_year: number; + start_definite: boolean; + end_definite: boolean; + era_id: number; + era_name?: string; + description: string; + color: string; +} + +export interface Artist { + id: number; + name: string; + birth_year: number; + death_year: number; + movement_id: number; + movement_name?: string; + movement_color?: string; + portrait_path: string; + bio_short: string; + bio_full: string; + wikipedia_title: string; + century: number; +} + +export interface ArtistPeriod { + id: number; + artist_id: number; + name: string; + start_year: number; + end_year: number; + description: string; + sort_order: number; +} + +export interface Painting { + id: number; + artist_id: number; + period_id: number; + title: string; + year: number; + year_end?: number; + description: string; + image_path: string; + thumbnail_path?: string; + wikipedia_title: string; + sort_order: number; + artist_name?: string; +} + +export interface InfluenceLink { + notes: string; + source: string; + aspects?: string; + quote?: string; + source_author?: string; + source_url?: string; + id: number; + title: string; + year: number; + image_path: string; + artist_name: string; + artist_id: number; +} + +export interface PaintingDetail { + painting: Painting & { artist_name: string; artist_portrait: string }; + influencedBy: InfluenceLink[]; + influenced: InfluenceLink[]; +} + +export interface ArtistDetail { + artist: Artist & { movement_name: string }; + periods: ArtistPeriod[]; + paintings: Painting[]; +} + +export interface TimelineData { + eras: HistoricalEra[]; + movements: ArtMovement[]; +} + +export interface YearBounds { + min_year: number; + max_year: number; +} + +export interface NavigationArtist { + id: number; + name: string; + birth_year: number | null; + death_year: number | null; + portrait_path: string | null; +} + +export interface MovementArtistGroup { + movement_id: number | null; + movement_name: string; + movement_color: string; + artists: NavigationArtist[]; +} + +export interface ArtistNavigation { + predecessors: MovementArtistGroup[]; + successors: MovementArtistGroup[]; +} diff --git a/server/index.js b/server/index.js index ad60c00..4ecf98b 100644 --- a/server/index.js +++ b/server/index.js @@ -98,6 +98,76 @@ app.get('/api/artists', async (req, res) => { } }); +// Artist detail with periods and paintings +app.get('/api/artists/:id/navigation', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + + const groupByMovement = (rows) => { + const map = new Map(); + for (const row of rows) { + const key = row.movement_id ?? 0; + if (!map.has(key)) { + map.set(key, { + movement_id: row.movement_id, + movement_name: row.movement_name || 'Other', + movement_color: row.movement_color || '#8B7355', + artists: [], + }); + } + const group = map.get(key); + if (!group.artists.some((a) => a.id === row.id)) { + group.artists.push({ + id: row.id, + name: row.name, + birth_year: row.birth_year, + death_year: row.death_year, + portrait_path: row.portrait_path, + }); + } + } + return Array.from(map.values()).sort((a, b) => + a.movement_name.localeCompare(b.movement_name) + ); + }; + + const [predecessors, successors] = await Promise.all([ + pool.query( + `SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path, + m.id AS movement_id, m.name AS movement_name, m.color AS movement_color + FROM painting_influences pi + JOIN paintings p_work ON pi.painting_id = p_work.id + JOIN paintings p_pred ON pi.influenced_by_painting_id = p_pred.id + JOIN artists a ON p_pred.artist_id = a.id + LEFT JOIN art_movements m ON a.movement_id = m.id + WHERE p_work.artist_id = $1 AND a.id <> $1 + ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`, + [artistId] + ), + pool.query( + `SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path, + m.id AS movement_id, m.name AS movement_name, m.color AS movement_color + FROM painting_influences pi + JOIN paintings p_src ON pi.influenced_by_painting_id = p_src.id + JOIN paintings p_work ON pi.painting_id = p_work.id + JOIN artists a ON p_work.artist_id = a.id + LEFT JOIN art_movements m ON a.movement_id = m.id + WHERE p_src.artist_id = $1 AND a.id <> $1 + ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`, + [artistId] + ), + ]); + + res.json({ + predecessors: groupByMovement(predecessors.rows), + successors: groupByMovement(successors.rows), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch artist navigation' }); + } +}); + // Artist detail with periods and paintings app.get('/api/artists/:id', async (req, res) => { try {