Add influence rework, image checkup, debug mode, and fetched paintings.
Support artist and movement influence links with web discovery, a developer checkup table with gallery/detail thumbnails, and debug image search with fix-it workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -164,8 +164,25 @@ Painting detail with influence graph neighbours.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "has_influence_links": true, ... },
|
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "has_influence_links": true, ... },
|
||||||
"influencedBy": [ { "id": 3, "title": "...", "artist_name": "...", "notes": "...", "aspects": "...", "quote": "...", "source_author": "...", ... } ],
|
"influencedBy": [
|
||||||
"influenced": [ { "id": 20, "title": "...", ... } ]
|
{
|
||||||
|
"source_type": "painting",
|
||||||
|
"id": 3, "title": "...", "artist_name": "...", "year": 1885,
|
||||||
|
"notes": "...", "aspects": "...", "quote": "...", "source_author": "...", "source_url": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_type": "artist",
|
||||||
|
"source_artist_id": 12, "source_artist_name": "Paul Cézanne",
|
||||||
|
"artist_portrait": "...", "period_note": "during creation (1907)",
|
||||||
|
"period_start_year": 1907, "period_end_year": 1907
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_type": "movement",
|
||||||
|
"movement_id": 8, "movement_name": "Fauvism", "movement_color": "#c45c26",
|
||||||
|
"period_start_year": 1905, "period_end_year": 1907
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ erDiagram
|
|||||||
artist_periods ||--o{ paintings : groups
|
artist_periods ||--o{ paintings : groups
|
||||||
paintings ||--o{ painting_influences : influenced_by
|
paintings ||--o{ painting_influences : influenced_by
|
||||||
paintings ||--o{ painting_influences : influences
|
paintings ||--o{ painting_influences : influences
|
||||||
|
paintings ||--o{ painting_influence_sources : influenced_by
|
||||||
|
artists ||--o{ painting_influence_sources : artist_source
|
||||||
|
art_movements ||--o{ painting_influence_sources : movement_source
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
@@ -114,7 +117,35 @@ Directed edges: *this painting* was influenced by *that painting*.
|
|||||||
|
|
||||||
Unique constraint on `(painting_id, influenced_by_painting_id)`.
|
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)).
|
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)). Legacy table; new polymorphic links live in `painting_influence_sources`.
|
||||||
|
|
||||||
|
### `painting_influence_sources`
|
||||||
|
|
||||||
|
Polymorphic influence links: *this painting* was influenced by a **painting**, **artist**, or **movement**.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | SERIAL PK | |
|
||||||
|
| `painting_id` | FK → `paintings` | The work being explained |
|
||||||
|
| `source_type` | VARCHAR(20) | `painting`, `artist`, or `movement` |
|
||||||
|
| `source_painting_id` | FK → `paintings` | When `source_type = painting` |
|
||||||
|
| `source_artist_id` | FK → `artists` | When `source_type = artist` |
|
||||||
|
| `source_movement_id` | FK → `art_movements` | When `source_type = movement` |
|
||||||
|
| `period_note` | VARCHAR(240) | Human-readable period label |
|
||||||
|
| `period_start_year` | INTEGER | Optional span start |
|
||||||
|
| `period_end_year` | INTEGER | Optional span end |
|
||||||
|
| `notes` | TEXT | Curator summary |
|
||||||
|
| `source` | VARCHAR(500) | General attribution |
|
||||||
|
| `aspects` | TEXT | What was borrowed |
|
||||||
|
| `quote` | TEXT | Short citation |
|
||||||
|
| `source_author` | VARCHAR(200) | e.g. Gombrich, Janson |
|
||||||
|
| `source_url` | VARCHAR(500) | Reference link |
|
||||||
|
| `discovered_via` | VARCHAR(120) | e.g. `wikipedia`, `wikidata`, `web:metmuseum.org` |
|
||||||
|
| `confidence` | VARCHAR(20) | `curated` (default) or `discovered` |
|
||||||
|
|
||||||
|
Unique index on `(painting_id, source_type, source_painting_id, source_artist_id, source_movement_id)` with COALESCE for null FKs.
|
||||||
|
|
||||||
|
Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_influence_links` and hall navigation (with legacy `painting_influences`).
|
||||||
|
|
||||||
## Indexes
|
## Indexes
|
||||||
|
|
||||||
@@ -122,6 +153,7 @@ Used by the 3D gallery exit panel: predecessors are artists of `influenced_by_pa
|
|||||||
- `paintings(artist_id)`, `paintings(period_id)`
|
- `paintings(artist_id)`, `paintings(period_id)`
|
||||||
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
|
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
|
||||||
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
|
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
|
||||||
|
- `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)`
|
||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,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; paintings on the walls, open centre, single exit for influence-based navigation.
|
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, 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), 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`).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`).
|
||||||
|
|
||||||
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).
|
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).
|
||||||
@@ -149,7 +149,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
|||||||
|
|
||||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks.
|
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks.
|
||||||
|
|
||||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting-to-painting influence edges (`painting_influences`) are separate and drive the 3D exit picker and detail panels.
|
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links (`painting_influence_sources`, plus legacy `painting_influences` for hall navigation) are separate and drive the 3D exit picker and detail panels.
|
||||||
|
|
||||||
## Virtual gallery (3D halls)
|
## Virtual gallery (3D halls)
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
|||||||
|
|
||||||
| Layer | What you see |
|
| Layer | What you see |
|
||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right), position in catalog (e.g. `3 of 12`) |
|
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails, artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
|
||||||
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
|
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
|
||||||
|
|
||||||
**Controls:**
|
**Controls:**
|
||||||
@@ -202,6 +202,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
|||||||
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
|
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
|
||||||
| Click centre image | Open fullscreen lightbox |
|
| Click centre image | Open fullscreen lightbox |
|
||||||
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
||||||
|
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||||||
| **← Back to Gallery** | Return to the hall you entered from — **3D camera position is preserved** |
|
| **← Back to Gallery** | Return to the hall you entered from — **3D camera position is preserved** |
|
||||||
| **About {artist}** | Open artist biography |
|
| **About {artist}** | Open artist biography |
|
||||||
|
|
||||||
@@ -217,9 +218,9 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
|||||||
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
||||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
||||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
- **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.
|
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
|
||||||
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
|
- **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.
|
- **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL).
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|||||||
@@ -113,22 +113,68 @@ Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael,
|
|||||||
|
|
||||||
## Painting influence graph
|
## Painting influence graph
|
||||||
|
|
||||||
Directed edges in `painting_influences` drive:
|
Directed influence links are stored in **`painting_influence_sources`**. Each row connects a painting to a **source** of type `painting`, `artist`, or `movement`, with optional period context (e.g. influence during the work’s creation year).
|
||||||
|
|
||||||
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels with notes, aspects, and citations
|
The legacy **`painting_influences`** table (painting-to-painting only) is still written for hall navigation compatibility and is backfilled into `painting_influence_sources` on migration.
|
||||||
- **3D hall exit** — predecessor and successor artists grouped by movement
|
|
||||||
- **3D gallery lamps** — a golden picture light above any frame whose work has an influence edge (`has_influence_links` on painting API responses)
|
Influence data drives:
|
||||||
|
|
||||||
|
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels: painting thumbnails, artist portraits, or movement colour swatches, plus notes, aspects, period labels, and citations
|
||||||
|
- **3D hall exit** — predecessor and successor artists grouped by movement (painting edges only)
|
||||||
|
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
|
||||||
|
|
||||||
|
### One-time migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:influence-sources # create table + backfill legacy painting edges
|
||||||
|
```
|
||||||
|
|
||||||
|
### Curated updates
|
||||||
|
|
||||||
`npm run update-influences` runs `scripts/update-influences.js` against `scripts/art-influences-data.js`:
|
`npm run update-influences` runs `scripts/update-influences.js` against `scripts/art-influences-data.js`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run update-influences # insert edges; create missing artists/paintings
|
npm run update-influences # insert curated edges
|
||||||
npm run update-influences -- --fetch-images # also download images for newly created works
|
npm run update-influences -- --fetch-images # also download images for newly created works
|
||||||
|
npm run update-influences -- --discover # curated + web discovery pass
|
||||||
|
npm run discover-influences # discovery only (no curated file pass)
|
||||||
|
npm run update-influences -- --discover --limit=20 # cap discovery to N works
|
||||||
```
|
```
|
||||||
|
|
||||||
Each entry defines a later `work` influenced by an earlier `influencedBy` painting, plus optional curator fields (`notes`, `aspects`, `source_author`, `source`). When `artistMeta` is included, missing artists are created with movement and lifespan. Missing paintings are inserted with `wikipedia_title` for image fetch.
|
Each entry defines a later `work` and one or more `influencedBy` sources. Legacy single-object form is still supported:
|
||||||
|
|
||||||
Extend the data file to add lineage chains (e.g. Giotto → Masaccio → Michelangelo → Manet → Picasso → Warhol). New bridge artists may include Géricault, Friedrich, Constable, Giorgione, Poussin, de Chirico, and Böcklin.
|
```javascript
|
||||||
|
{ work: { artist: '...', title: '...', year: 1907 },
|
||||||
|
influencedBy: { artist: 'Giotto', title: 'Lamentation' } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-source form (painting, artist, movement):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
work: { artist: 'Pablo Picasso', title: "Les Demoiselles d'Avignon", year: 1907 },
|
||||||
|
influencedBy: [
|
||||||
|
{ type: 'painting', artist: 'Paul Cézanne', title: 'The Bathers' },
|
||||||
|
{ type: 'artist', artist: 'Paul Cézanne', period: { duringCreation: true } },
|
||||||
|
{ type: 'movement', movement: 'Fauvism', period: { start: 1905, end: 1907, note: '...' } },
|
||||||
|
],
|
||||||
|
notes, aspects, source_author, source, source_url,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `artistMeta` is included, missing artists are created with movement and lifespan. Missing paintings are inserted with `wikipedia_title` for image fetch.
|
||||||
|
|
||||||
|
### Web discovery
|
||||||
|
|
||||||
|
`scripts/influence-discovery.js` searches art-history sources when `--discover` or `--discover-only` is passed:
|
||||||
|
|
||||||
|
- Wikipedia summaries and Wikidata **P737** (influenced by)
|
||||||
|
- Met Museum collection API
|
||||||
|
- DuckDuckGo site-restricted search across TheArtStory, Met, Google Arts & Culture, NGA, Art Institute of Chicago, MoMA, Britannica, JSTOR, Oxford Art Online, WikiArt, and Wikipedia
|
||||||
|
|
||||||
|
Discovered rows are stored with `confidence: discovered` and `discovered_via` (e.g. `wikipedia`, `wikidata`, `met`, `web:theartstory.org`). Period hints are inferred when the source text mentions influence during creation or a date range overlapping the work’s year.
|
||||||
|
|
||||||
|
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
|
||||||
|
|
||||||
## Movement lineage (frontend flow diagram)
|
## Movement lineage (frontend flow diagram)
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,11 @@ Open http://localhost:3001 (or your configured `PORT`).
|
|||||||
| `npm run migrate:thumbnails` | `db/migrate-thumbnails.sql` | Add thumbnail columns |
|
| `npm run migrate:thumbnails` | `db/migrate-thumbnails.sql` | Add thumbnail columns |
|
||||||
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* |
|
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* |
|
||||||
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
|
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
|
||||||
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence edges from `art-influences-data.js` |
|
| `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
|
||||||
|
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
|
||||||
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
|
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
|
||||||
|
| `npm run update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) |
|
||||||
|
| `npm run discover-influences` | `update-influences.js --discover-only` | Discovery pass only |
|
||||||
|
|
||||||
### Scripts in the repository
|
### Scripts in the repository
|
||||||
|
|
||||||
@@ -121,7 +124,8 @@ These are checked in and maintained:
|
|||||||
- `image-fetcher.js` — Wikimedia / museum image resolution
|
- `image-fetcher.js` — Wikimedia / museum image resolution
|
||||||
- `fetch-artist-bios.js` — artist biographies
|
- `fetch-artist-bios.js` — artist biographies
|
||||||
- `expand-paintings.js` + `famous-paintings-data.js` — catalog expansion
|
- `expand-paintings.js` + `famous-paintings-data.js` — catalog expansion
|
||||||
- `update-influences.js` + `art-influences-data.js` — painting influence graph
|
- `update-influences.js` + `art-influences-data.js` — influence graph (paintings, artists, movements)
|
||||||
|
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
|
||||||
- `fetch-missing-images.js` — batch image backfill
|
- `fetch-missing-images.js` — batch image backfill
|
||||||
- `regenerate-thumbnails.js`, `audit-painting-images.js`
|
- `regenerate-thumbnails.js`, `audit-painting-images.js`
|
||||||
|
|
||||||
@@ -153,7 +157,7 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
|
|||||||
| Wrong painting in 3D gallery frame | Stale or mismatched thumbnail file | `npm run regenerate-thumbnails`; gallery prefers full `image_path` |
|
| Wrong painting in 3D gallery frame | Stale or mismatched thumbnail file | `npm run regenerate-thumbnails`; gallery prefers full `image_path` |
|
||||||
| Wrong painting image (fetch) | Bad museum / search match on first download | Add entry to `DIRECT_IMAGE_OVERRIDES` in `scripts/image-fetcher.js`, re-fetch file |
|
| Wrong painting image (fetch) | Bad museum / search match on first download | Add entry to `DIRECT_IMAGE_OVERRIDES` in `scripts/image-fetcher.js`, re-fetch file |
|
||||||
| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` |
|
| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` |
|
||||||
| Empty *Influenced By* / *Influenced* on painting detail | No edges for that work | `npm run update-influences`; extend `art-influences-data.js` |
|
| Empty *Influenced By* / *Influenced* on painting detail | No edges for that work | `npm run migrate:influence-sources` then `npm run update-influences`; extend `art-influences-data.js` or run `--discover` |
|
||||||
| No golden lamps above frames in 3D hall | Stale API process or no influence edges | Restart server after API changes; run `npm run update-influences` |
|
| No golden lamps above frames in 3D hall | Stale API process or no influence edges | Restart server after API changes; run `npm run update-influences` |
|
||||||
| Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` |
|
| 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 |
|
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
|
||||||
|
|||||||
@@ -46,6 +46,34 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DebugImageSearchResult {
|
||||||
|
query: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
searchUrl: string;
|
||||||
|
source: string;
|
||||||
|
sourceLabel?: string;
|
||||||
|
thumbUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaintingCheckupRow {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist_name: string;
|
||||||
|
year: number | null;
|
||||||
|
gallery_file: string | null;
|
||||||
|
gallery_preview: string | null;
|
||||||
|
detail_file: string;
|
||||||
|
detail_preview: string | null;
|
||||||
|
detail_on_demand: boolean;
|
||||||
|
gallery_file_exists: boolean;
|
||||||
|
detail_file_exists: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaintingCheckupData {
|
||||||
|
paintings: PaintingCheckupRow[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
||||||
|
|
||||||
@@ -66,4 +94,26 @@ export const api = {
|
|||||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||||
|
|
||||||
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
||||||
|
|
||||||
|
getPaintingDebugImageSearch: (id: number) =>
|
||||||
|
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||||
|
|
||||||
|
fixPaintingImage: (id: number, imageUrl: string) =>
|
||||||
|
fetch(`${API}/paintings/${id}/fix-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imageUrl }),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Fix failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ imagePath: string; thumbnailPath: string }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function debugImageProxyUrl(imageUrl: string): string {
|
||||||
|
return `${API}/debug/image-proxy?url=${encodeURIComponent(imageUrl)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.painting-detail {
|
.painting-detail {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%);
|
background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%);
|
||||||
color: #e8d5b5;
|
color: #e8d5b5;
|
||||||
@@ -164,6 +165,48 @@
|
|||||||
color: #c9a96e;
|
color: #c9a96e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.influence-title-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-card-movement {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-movement-swatch {
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-card-artist .influence-portrait-btn img {
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-period {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #a89070;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-discovered-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7a6a55;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-title-btn:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.influence-image-btn:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.influence-aspects {
|
.influence-aspects {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -366,6 +409,80 @@
|
|||||||
color: rgba(232, 213, 181, 0.85);
|
color: rgba(232, 213, 181, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-image-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 60;
|
||||||
|
width: min(280px, calc(100vw - 32px));
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(232, 160, 64, 0.45);
|
||||||
|
background: rgba(15, 15, 26, 0.94);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-image-panel h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #e8a040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-image-query {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: rgba(232, 213, 181, 0.75);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-image-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 160px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #2a1f15;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-fix-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e8a040;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(232, 160, 64, 0.15);
|
||||||
|
color: #e8a040;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-fix-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(232, 160, 64, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-fix-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-image-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(201, 169, 110, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-image-error {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.painting-layout {
|
.painting-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type SyntheticEvent } from 'react';
|
import { useEffect, useState, type SyntheticEvent } from 'react';
|
||||||
import type { Painting, PaintingDetail } from '../types';
|
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||||
import { paintingImageUrl } from '../api/client';
|
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult } from '../api/client';
|
||||||
import PaintingLightbox from './PaintingLightbox';
|
import PaintingLightbox from './PaintingLightbox';
|
||||||
import './PaintingDetail.css';
|
import './PaintingDetail.css';
|
||||||
|
|
||||||
@@ -11,42 +11,43 @@ interface Props {
|
|||||||
onPaintingClick: (paintingId: number) => void;
|
onPaintingClick: (paintingId: number) => void;
|
||||||
onCatalogNavigate: (paintingId: number) => void;
|
onCatalogNavigate: (paintingId: number) => void;
|
||||||
onArtistBio: () => void;
|
onArtistBio: () => void;
|
||||||
|
onInfluenceArtistClick?: (artistId: number) => void;
|
||||||
|
debugMode?: boolean;
|
||||||
|
onPaintingImageFixed?: (paintingId: number) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function influenceKey(inf: InfluenceLink, index: number): string {
|
||||||
|
if (inf.source_type === 'movement') return `movement-${inf.movement_id ?? inf.movement_name}-${index}`;
|
||||||
|
if (inf.source_type === 'artist') return `artist-${inf.source_artist_id ?? inf.source_artist_name}-${index}`;
|
||||||
|
return `painting-${inf.id}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLabel(inf: InfluenceLink): string | null {
|
||||||
|
if (inf.period_note) return inf.period_note;
|
||||||
|
if (inf.period_start_year != null && inf.period_end_year != null) {
|
||||||
|
return `${inf.period_start_year}–${inf.period_end_year}`;
|
||||||
|
}
|
||||||
|
if (inf.period_start_year != null) return `from ${inf.period_start_year}`;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfluenceCard({
|
function InfluenceCard({
|
||||||
inf,
|
inf,
|
||||||
onPaintingClick,
|
onPaintingClick,
|
||||||
|
onInfluenceArtistClick,
|
||||||
}: {
|
}: {
|
||||||
inf: PaintingDetail['influencedBy'][0];
|
inf: InfluenceLink;
|
||||||
onPaintingClick: (id: number) => void;
|
onPaintingClick: (id: number) => void;
|
||||||
|
onInfluenceArtistClick?: (artistId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const aspects = inf.aspects
|
const aspects = inf.aspects
|
||||||
? inf.aspects.split(',').map((a) => a.trim()).filter(Boolean)
|
? inf.aspects.split(',').map((a) => a.trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const period = periodLabel(inf);
|
||||||
|
const sourceType = inf.source_type || 'painting';
|
||||||
|
|
||||||
return (
|
const meta = (
|
||||||
<article className="influence-card-expanded">
|
<>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="influence-image-btn"
|
|
||||||
onClick={() => onPaintingClick(inf.id)}
|
|
||||||
title={`View ${inf.title}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
|
|
||||||
alt={inf.title}
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="influence-body">
|
|
||||||
<button type="button" className="influence-title-btn" onClick={() => onPaintingClick(inf.id)}>
|
|
||||||
<strong>{inf.title}</strong>
|
|
||||||
<span>{inf.artist_name}{inf.year ? `, ${inf.year}` : ''}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{aspects.length > 0 && (
|
{aspects.length > 0 && (
|
||||||
<div className="influence-aspects">
|
<div className="influence-aspects">
|
||||||
{aspects.map((aspect) => (
|
{aspects.map((aspect) => (
|
||||||
@@ -54,9 +55,8 @@ function InfluenceCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{period && <p className="influence-period">{period}</p>}
|
||||||
{inf.notes && <p className="influence-notes">{inf.notes}</p>}
|
{inf.notes && <p className="influence-notes">{inf.notes}</p>}
|
||||||
|
|
||||||
{inf.quote && (
|
{inf.quote && (
|
||||||
<blockquote className="influence-quote">
|
<blockquote className="influence-quote">
|
||||||
<p>“{inf.quote}”</p>
|
<p>“{inf.quote}”</p>
|
||||||
@@ -68,7 +68,6 @@ function InfluenceCard({
|
|||||||
)}
|
)}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inf.source_url && (
|
{inf.source_url && (
|
||||||
<a
|
<a
|
||||||
className="influence-source-link"
|
className="influence-source-link"
|
||||||
@@ -80,6 +79,92 @@ function InfluenceCard({
|
|||||||
Read source: {inf.source_author || inf.source || 'Reference'}
|
Read source: {inf.source_author || inf.source || 'Reference'}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{inf.confidence === 'discovered' && inf.discovered_via && (
|
||||||
|
<span className="influence-discovered-tag">Discovered via {inf.discovered_via}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceType === 'movement') {
|
||||||
|
return (
|
||||||
|
<article className="influence-card-expanded influence-card-movement">
|
||||||
|
<div
|
||||||
|
className="influence-movement-swatch"
|
||||||
|
style={{ background: inf.movement_color || '#8B7355' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="influence-body">
|
||||||
|
<div className="influence-title-btn influence-title-static">
|
||||||
|
<strong>{inf.movement_name}</strong>
|
||||||
|
<span>Art movement</span>
|
||||||
|
</div>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType === 'artist') {
|
||||||
|
const artistId = inf.source_artist_id;
|
||||||
|
const artistName = inf.source_artist_name || 'Unknown artist';
|
||||||
|
return (
|
||||||
|
<article className="influence-card-expanded influence-card-artist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="influence-image-btn influence-portrait-btn"
|
||||||
|
onClick={() => artistId && onInfluenceArtistClick?.(artistId)}
|
||||||
|
title={`View ${artistName}`}
|
||||||
|
disabled={!artistId || !onInfluenceArtistClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl(inf.artist_portrait)}
|
||||||
|
alt={artistName}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="influence-body">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="influence-title-btn"
|
||||||
|
onClick={() => artistId && onInfluenceArtistClick?.(artistId)}
|
||||||
|
disabled={!artistId || !onInfluenceArtistClick}
|
||||||
|
>
|
||||||
|
<strong>{artistName}</strong>
|
||||||
|
<span>Artist influence</span>
|
||||||
|
</button>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inf.id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="influence-card-expanded">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="influence-image-btn"
|
||||||
|
onClick={() => onPaintingClick(inf.id!)}
|
||||||
|
title={`View ${inf.title}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
|
||||||
|
alt={inf.title || 'Painting'}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="influence-body">
|
||||||
|
<button type="button" className="influence-title-btn" onClick={() => onPaintingClick(inf.id!)}>
|
||||||
|
<strong>{inf.title}</strong>
|
||||||
|
<span>{inf.artist_name}{inf.year ? `, ${inf.year}` : ''}</span>
|
||||||
|
</button>
|
||||||
|
{meta}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -92,10 +177,19 @@ export default function PaintingDetailView({
|
|||||||
onPaintingClick,
|
onPaintingClick,
|
||||||
onCatalogNavigate,
|
onCatalogNavigate,
|
||||||
onArtistBio,
|
onArtistBio,
|
||||||
|
onInfluenceArtistClick,
|
||||||
|
debugMode = false,
|
||||||
|
onPaintingImageFixed,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { painting, influencedBy, influenced } = data;
|
const { painting, influencedBy, influenced } = data;
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const imageSrc = paintingImageUrl(painting);
|
const [imageVersion, setImageVersion] = useState(0);
|
||||||
|
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||||
|
const [debugLoading, setDebugLoading] = useState(false);
|
||||||
|
const [debugError, setDebugError] = useState<string | null>(null);
|
||||||
|
const [fixing, setFixing] = useState(false);
|
||||||
|
|
||||||
|
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
|
||||||
|
|
||||||
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||||
@@ -107,8 +201,52 @@ export default function PaintingDetailView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFullscreen(false);
|
setFullscreen(false);
|
||||||
|
setImageVersion(0);
|
||||||
}, [painting.id]);
|
}, [painting.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugMode) {
|
||||||
|
setDebugSearch(null);
|
||||||
|
setDebugError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setDebugLoading(true);
|
||||||
|
setDebugError(null);
|
||||||
|
setDebugSearch(null);
|
||||||
|
|
||||||
|
api.getPaintingDebugImageSearch(painting.id)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setDebugSearch(result);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setDebugError('Google image search failed.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setDebugLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [debugMode, painting.id, painting.title, painting.artist_name]);
|
||||||
|
|
||||||
|
const handleFixImage = async () => {
|
||||||
|
if (!debugSearch?.imageUrl || fixing) return;
|
||||||
|
setFixing(true);
|
||||||
|
setDebugError(null);
|
||||||
|
try {
|
||||||
|
await api.fixPaintingImage(painting.id, debugSearch.imageUrl);
|
||||||
|
setImageVersion((v) => v + 1);
|
||||||
|
await onPaintingImageFixed?.(painting.id);
|
||||||
|
} catch (err) {
|
||||||
|
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||||
|
} finally {
|
||||||
|
setFixing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fullscreen) return;
|
if (fullscreen) return;
|
||||||
|
|
||||||
@@ -159,8 +297,13 @@ export default function PaintingDetailView({
|
|||||||
<p className="no-influences">No documented influences for this work.</p>
|
<p className="no-influences">No documented influences for this work.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="influence-list">
|
<div className="influence-list">
|
||||||
{influencedBy.map((inf) => (
|
{influencedBy.map((inf, index) => (
|
||||||
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
|
<InfluenceCard
|
||||||
|
key={influenceKey(inf, index)}
|
||||||
|
inf={inf}
|
||||||
|
onPaintingClick={onPaintingClick}
|
||||||
|
onInfluenceArtistClick={onInfluenceArtistClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -226,14 +369,53 @@ export default function PaintingDetailView({
|
|||||||
<p className="no-influences">No documented works influenced by this painting yet.</p>
|
<p className="no-influences">No documented works influenced by this painting yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="influence-list">
|
<div className="influence-list">
|
||||||
{influenced.map((inf) => (
|
{influenced.map((inf, index) => (
|
||||||
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
|
<InfluenceCard
|
||||||
|
key={influenceKey(inf, index)}
|
||||||
|
inf={inf}
|
||||||
|
onPaintingClick={onPaintingClick}
|
||||||
|
onInfluenceArtistClick={onInfluenceArtistClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{debugMode && (
|
||||||
|
<aside className="debug-image-panel" aria-label="Debug image search">
|
||||||
|
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
|
||||||
|
<p className="debug-image-query">
|
||||||
|
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
|
||||||
|
</p>
|
||||||
|
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||||
|
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||||
|
{!debugLoading && debugSearch?.imageUrl && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className="debug-image-preview"
|
||||||
|
src={debugImageProxyUrl(debugSearch.imageUrl)}
|
||||||
|
alt={`Google search result for ${debugSearch.query}`}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-fix-btn"
|
||||||
|
onClick={handleFixImage}
|
||||||
|
disabled={fixing}
|
||||||
|
>
|
||||||
|
{fixing ? 'Replacing…' : 'Fix it'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||||
|
<p className="debug-image-status">No Google image result found.</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
<PaintingLightbox
|
<PaintingLightbox
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
.checkup-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-back-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-back-btn:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-title-block h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-title-block p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(201, 169, 110, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 20px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-filter {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||||
|
background: rgba(15, 15, 26, 0.9);
|
||||||
|
color: #e8d5b5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
color: rgba(201, 169, 110, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-error {
|
||||||
|
margin: 0 24px 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(139, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 100, 100, 0.4);
|
||||||
|
color: #ffaaaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-loading {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(201, 169, 110, 0.6);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(26, 26, 46, 0.98);
|
||||||
|
border-bottom: 2px solid rgba(201, 169, 110, 0.35);
|
||||||
|
color: #c9a96e;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(201, 169, 110, 0.12);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-cell {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #2a1f15;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-empty {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: rgba(201, 169, 110, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-empty small {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-missing {
|
||||||
|
border-color: rgba(255, 138, 128, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(15, 15, 26, 0.88);
|
||||||
|
border: 1px solid rgba(255, 138, 128, 0.45);
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
color: #ff8a80;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-thumb-badge-api {
|
||||||
|
border-color: rgba(158, 197, 232, 0.45);
|
||||||
|
color: #9ec5e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-table tbody tr:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-year {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: rgba(232, 213, 181, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-title-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #e8d5b5;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(201, 169, 110, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-title-link:hover {
|
||||||
|
color: #c9a96e;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api, type PaintingCheckupRow } from '../api/client';
|
||||||
|
import './CheckupPage.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenPainting?: (paintingId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewSrc(row: PaintingCheckupRow, kind: 'gallery' | 'detail'): string | null {
|
||||||
|
if (kind === 'gallery') {
|
||||||
|
const file = row.gallery_preview ?? row.gallery_file;
|
||||||
|
return file ? `/images/${file}` : null;
|
||||||
|
}
|
||||||
|
if (row.detail_on_demand) {
|
||||||
|
return `/api/paintings/${row.id}/image?size=thumb`;
|
||||||
|
}
|
||||||
|
const file = row.detail_preview ?? row.detail_file;
|
||||||
|
return file ? `/images/${file}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePreviewCell({
|
||||||
|
row,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
row: PaintingCheckupRow;
|
||||||
|
kind: 'gallery' | 'detail';
|
||||||
|
}) {
|
||||||
|
const path = kind === 'gallery' ? row.gallery_file : row.detail_file;
|
||||||
|
const src = previewSrc(row, kind);
|
||||||
|
const exists =
|
||||||
|
kind === 'gallery' ? row.gallery_file_exists : row.detail_file_exists;
|
||||||
|
const missing = path && exists === false;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return (
|
||||||
|
<div className="checkup-thumb checkup-thumb-empty">
|
||||||
|
<span>No image</span>
|
||||||
|
<small>Hidden in 3D</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`checkup-thumb${missing ? ' checkup-thumb-missing' : ''}`}>
|
||||||
|
{src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
title={path}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{missing && <span className="checkup-thumb-badge">missing file</span>}
|
||||||
|
{row.detail_on_demand && kind === 'detail' && (
|
||||||
|
<span className="checkup-thumb-badge checkup-thumb-badge-api">on-demand</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
||||||
|
const [rows, setRows] = useState<PaintingCheckupRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
api.getPaintingCheckup()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRows(data.paintings);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError('Could not load checkup data. Is the server running?');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) return rows;
|
||||||
|
return rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.title.toLowerCase().includes(q) ||
|
||||||
|
row.artist_name.toLowerCase().includes(q) ||
|
||||||
|
String(row.year ?? '').includes(q) ||
|
||||||
|
(row.gallery_file ?? '').toLowerCase().includes(q) ||
|
||||||
|
row.detail_file.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [rows, filter]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const noGallery = rows.filter((r) => !r.gallery_file).length;
|
||||||
|
const onDemand = rows.filter((r) => r.detail_on_demand).length;
|
||||||
|
const missingGallery = rows.filter((r) => r.gallery_file && !r.gallery_file_exists).length;
|
||||||
|
const missingDetail = rows.filter(
|
||||||
|
(r) => r.detail_file_exists === false
|
||||||
|
).length;
|
||||||
|
return { noGallery, onDemand, missingGallery, missingDetail };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="checkup-page">
|
||||||
|
<header className="checkup-header">
|
||||||
|
<button type="button" className="checkup-back-btn" onClick={onBack}>
|
||||||
|
← Back to timeline
|
||||||
|
</button>
|
||||||
|
<div className="checkup-title-block">
|
||||||
|
<h1>Painting checkup</h1>
|
||||||
|
<p>Compare image files used in the 3D gallery vs painting detail view.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="checkup-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="checkup-filter"
|
||||||
|
placeholder="Filter by title, artist, year…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="checkup-stats">
|
||||||
|
<span>{filtered.length} shown</span>
|
||||||
|
<span>{stats.noGallery} no gallery file</span>
|
||||||
|
<span>{stats.onDemand} detail on-demand</span>
|
||||||
|
<span>{stats.missingGallery} gallery missing</span>
|
||||||
|
<span>{stats.missingDetail} detail missing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="checkup-error">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="checkup-loading">Loading paintings…</div>
|
||||||
|
) : (
|
||||||
|
<div className="checkup-table-wrap">
|
||||||
|
<table className="checkup-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Painting</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Gallery</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>
|
||||||
|
{onOpenPainting ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="checkup-title-link"
|
||||||
|
onClick={() => onOpenPainting(row.id)}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
row.title
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{row.artist_name}</td>
|
||||||
|
<td className="checkup-year">{row.year ?? '—'}</td>
|
||||||
|
<td className="checkup-thumb-cell">
|
||||||
|
<ImagePreviewCell row={row} kind="gallery" />
|
||||||
|
</td>
|
||||||
|
<td className="checkup-thumb-cell">
|
||||||
|
<ImagePreviewCell row={row} kind="detail" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,45 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px 16px 8px;
|
padding: 24px 16px 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-dev-tools {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-mode-toggle,
|
||||||
|
.checkup-link-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: rgba(201, 169, 110, 0.75);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-mode-toggle:hover,
|
||||||
|
.checkup-link-btn:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-mode-toggle-active {
|
||||||
|
border-color: #e8a040;
|
||||||
|
background: rgba(232, 160, 64, 0.15);
|
||||||
|
color: #e8a040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkup-link-btn {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header h1 {
|
.site-header h1 {
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import MovementBands from '../components/MovementBands';
|
|||||||
import VirtualGallery from '../components/VirtualGallery';
|
import VirtualGallery from '../components/VirtualGallery';
|
||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
|
import CheckupPage from '../pages/CheckupPage';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
|
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||||
import './HomePage.css';
|
import './HomePage.css';
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| { type: 'timeline' }
|
| { type: 'timeline' }
|
||||||
|
| { type: 'checkup' }
|
||||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||||
@@ -28,6 +31,7 @@ export default function HomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||||
|
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +80,51 @@ export default function HomePage() {
|
|||||||
setViewEnd(end);
|
setViewEnd(end);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleDebugMode = () => {
|
||||||
|
setDebugMode((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeDebugMode(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaintingImageFixed = useCallback(async (paintingId: number) => {
|
||||||
|
const data = await api.getPainting(paintingId);
|
||||||
|
setView((current) =>
|
||||||
|
current.type === 'painting' && current.paintingId === paintingId
|
||||||
|
? { ...current, data }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
setDetailArtistPaintings((list) =>
|
||||||
|
list.map((p) =>
|
||||||
|
p.id === paintingId
|
||||||
|
? { ...p, image_path: data.painting.image_path, thumbnail_path: data.painting.thumbnail_path }
|
||||||
|
: p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (gallerySession?.artistId === data.painting.artist_id) {
|
||||||
|
setGallerySession((session) =>
|
||||||
|
session
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
data: {
|
||||||
|
...session.data,
|
||||||
|
paintings: session.data.paintings.map((p) =>
|
||||||
|
p.id === paintingId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
image_path: data.painting.image_path,
|
||||||
|
thumbnail_path: data.painting.thumbnail_path,
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: session
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [gallerySession]);
|
||||||
|
|
||||||
const handleArtistClick = async (artistId: number) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getArtist(artistId);
|
const data = await api.getArtist(artistId);
|
||||||
@@ -182,6 +231,9 @@ export default function HomePage() {
|
|||||||
const artistData = await api.getArtist(view.data.painting.artist_id);
|
const artistData = await api.getArtist(view.data.painting.artist_id);
|
||||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||||
}}
|
}}
|
||||||
|
onInfluenceArtistClick={handleArtistClick}
|
||||||
|
debugMode={debugMode}
|
||||||
|
onPaintingImageFixed={handlePaintingImageFixed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -198,9 +250,34 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view.type === 'checkup' && (
|
||||||
|
<CheckupPage
|
||||||
|
onBack={() => setView({ type: 'timeline' })}
|
||||||
|
onOpenPainting={handlePaintingClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{view.type === 'timeline' && (
|
{view.type === 'timeline' && (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
|
<div className="site-dev-tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||||
|
onClick={toggleDebugMode}
|
||||||
|
title="Toggle developer image audit mode on painting details"
|
||||||
|
>
|
||||||
|
Debug mode{debugMode ? ': ON' : ''}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="checkup-link-btn"
|
||||||
|
onClick={() => setView({ type: 'checkup' })}
|
||||||
|
title="Open painting image checkup table"
|
||||||
|
>
|
||||||
|
Checkup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<h1>Virtual Art Gallery</h1>
|
<h1>Virtual Art Gallery</h1>
|
||||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -63,19 +63,33 @@ export interface Painting {
|
|||||||
has_influence_links?: boolean | string | number;
|
has_influence_links?: boolean | string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InfluenceSourceType = 'painting' | 'artist' | 'movement';
|
||||||
|
|
||||||
export interface InfluenceLink {
|
export interface InfluenceLink {
|
||||||
notes: string;
|
source_type: InfluenceSourceType;
|
||||||
source: string;
|
notes?: string;
|
||||||
|
source?: string;
|
||||||
aspects?: string;
|
aspects?: string;
|
||||||
quote?: string;
|
quote?: string;
|
||||||
source_author?: string;
|
source_author?: string;
|
||||||
source_url?: string;
|
source_url?: string;
|
||||||
id: number;
|
confidence?: string;
|
||||||
title: string;
|
discovered_via?: string;
|
||||||
year: number;
|
period_note?: string;
|
||||||
image_path: string;
|
period_start_year?: number | null;
|
||||||
artist_name: string;
|
period_end_year?: number | null;
|
||||||
artist_id: number;
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
year?: number | null;
|
||||||
|
image_path?: string | null;
|
||||||
|
artist_name?: string;
|
||||||
|
artist_id?: number;
|
||||||
|
source_artist_id?: number;
|
||||||
|
source_artist_name?: string;
|
||||||
|
artist_portrait?: string | null;
|
||||||
|
movement_id?: number;
|
||||||
|
movement_name?: string;
|
||||||
|
movement_color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaintingDetail {
|
export interface PaintingDetail {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const DEBUG_MODE_KEY = 'gallery-debug-mode';
|
||||||
|
|
||||||
|
export function readDebugMode(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DEBUG_MODE_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDebugMode(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DEBUG_MODE_KEY, enabled ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 185 MiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 954 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 9.0 MiB |
|
After Width: | Height: | Size: 47 MiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 34 MiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 713 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 8.4 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |