Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 1cb9eef935 Rework Byzantine hall as a late-antique basilica interior.
Replace the flat gold-mosaic chapel with marble revetment walls, a
Cosmatesque stone floor, and a coffered timber ceiling, matching
Santa Sabina / San Vitale reference interiors.

New procedural surfaces in galleryProceduralTextures.ts:
- marble-revetment: book-matched veined panels in moulded travertine
  surrounds (each panel mirrored about its centre)
- marble-opus-sectile: cut-stone bays with diagonal diamond inlays and
  corner triangles in porphyry, verd-antique, giallo, and grey
- coffered-wood: recessed walnut coffers with lit beam chamfers

Add a byzantine detail set to MovementHallDetails.tsx: engaged porphyry
colonnettes with white-marble basket capitals and impost blocks carrying
the ceiling, a marble revetment dado, and hanging brass polycandela.
Colonnettes stay flush in the corner pockets so they do not clip the
~0.7m frame hang margin.

Basilicas are lit by shafts from high windows rather than evenly
flooded, but the shared hall lights carry a brightness floor that
prevented dimming. Add an opt-in lightScale to the interior style that
scales the scene, hall, track, and HDR environment lights. It defaults
to 1, so all other halls are unchanged; only Byzantine opts down. Window
fill lights are left unscaled so the daylight shafts still read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:59:56 +03:00
Danila KhodjaefandCursor 1853222e01 Fix movement-hall black walls and column clipping.
Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 00:26:14 +03:00
Danila KhodjaefandCursor 710d262516 Speed up large movement gallery entry (Byzantine)
Hall frames use thumbs only (never multi-MB originals). Preload no longer blocks open; texture downloads are queued; boot overlay no longer waits on HDR Environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:40:06 +03:00
Danila KhodjaefandCursor 0a5918c4dd Add movement artist filter modal and fix large-hall texture blanks
Clicking a movement opens ArtistFilterModal to choose artists before the gallery. Large halls no longer permanently blank frames when texture loads exceed the overlay deadline; fall back thumb to full to on-demand API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:05:54 +03:00
Danila KhodjaefandCursor ad1572b3aa Improve movement label placement and lane corridor clearing
Label priority: center, right-end inside, overflow right/left (prefer side with fewer transition-line overlaps), adjacent gap, hover-only. Exclude own portraits from collision. Min stream height 25px. Corridor clearing: 20 passes, prefer above, re-attract after move.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:13:55 +03:00
Danila KhodjaefandCursor 8111cd0163 Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:36:55 +03:00
Danila Khodjaef 23e1210e86 Release: weekly deploy 2026-07-27 20:14:51 +03:00
Danila Khodjaef 485c7d3e6f Release: weekly deploy 2026-07-27 19:29:19 +03:00
Danila KhodjaefandCursor 08ff4651e2 Enlarge movement-flow artist portraits to 150% size.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:07:58 +03:00
Danila KhodjaefandCursor 0466b77328 Add curator roles/permissions with Users admin, and fix lineage branch joins.
Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:17 +03:00
Danila KhodjaefandCursor bfa21989c9 Require painting thumb regen on every picture change and document the invariant.
Fix disk sync, ensure, and preload paths that previously reused the full image as a thumb.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:45:46 +03:00
Danila KhodjaefandCursor 67594ea4d3 Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:33:36 +03:00
Danila Khodjaef 41d2d5d844 Release: weekly deploy 2026-07-27 13:25:43 +03:00
Danila KhodjaefandCursor 8d3ebcce60 Improve movement flow layout: smaller hover portraits, on-band labels, and clear lineage corridors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:16:20 +03:00
Danila KhodjaefandCursor 89e39d408a Fix harmonize:images thumb rebuild and allow oversized museum scans.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:23:25 +03:00
Danila KhodjaefandCursor 6656f91f25 Document Gariff influence workbook and pack timeline lanes by time span.
Add Inputs/gariff_influential_painters_influences.xlsx for curator import, document Inputs/Output conventions, and let non-overlapping movements share horizontal lanes on the timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:32:59 +03:00
Danila Khodjaef 8005252881 Release: weekly deploy 2026-07-26 12:14:41 +03:00
Danila KhodjaefandCursor d709e98640 Merge remote main: upside-down influence lamps with local hall fixes.
Keep remote lamp orientation, drop castShadow (shadows stay disabled), and retain curator-note plates plus large-hall loading docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:40:55 +03:00
Danila KhodjaefandCursor 8e444fa461 Fix harmonize prod DB targeting and large-hall loading hangs.
Prod env files now win over root .env so harmonize:db can open gallery_prod after gallery_dev; tour_stops gains updated_at; halls no longer block the overlay on every painting texture. Docs cover the fixes and one-artist image pull from prod; Duccio painting assets synced from TrueNAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:26:51 +03:00
Danila KhodjaefandCursor f6c73c1792 Smooth 3D hall boot: disable shadows and warm door shaders under the loading overlay.
Keep Loading gallery until Environment and gl.compileAsync finish (with timeouts), GPU-upload painting textures before dismiss, and drop Canvas shadow maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:32:10 +03:00
Danila KhodjaefandCursor bc8369e373 Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00
Danila Khodjaef 77befd94b2 Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery 2026-07-22 23:14:09 +03:00
Danila KhodjaefandCursor cc39c2b138 Flip influence-link picture lamps upside down in the 3D hall.
Document the inverted InfluencePictureLamp mount so link indicators match the intended fixture orientation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:12:54 +03:00
Danila KhodjaefandCursor 5ddc3fd7f0 Add guided tours and unify left-to-right hall wall hang.
Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 20:27:36 +03:00
Danila KhodjaefandCursor 48bd17e985 Add curator Influences tool with import wizard, CRUD, and graph.
CSV/JSON/XLSX mapping wizard expands artist-level rows to all paintings, blocks duplicate file/data imports via content and payload hashes, and documents the workflow in influence-import.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 18:54:26 +03:00
Danila KhodjaefandCursor 62d7ebbe6a Document prod schema migrate before restore and i18n search index fix.
Release runbook now describes default migrateProdSchema, auto-migrate safety guard, and troubleshooting for updated_at restore failures; i18n docs cover partial entity_translations index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 12:21:53 +03:00
Danila Khodjaef a2c27ea526 Release: weekly deploy 2026-07-15 12:10:57 +03:00
Danila KhodjaefandCursor ca58c43648 Add Russian i18n with DB translations, locale API, and curator review UI.
UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:51:46 +03:00
Danila KhodjaefandCursor f247b418d8 Add dev-prod harmonize tool for bidirectional catalog and image sync.
Schema migrates dev to prod only; catalog rows merge by updated_at and images by mtime with union merge and conflict reporting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:02:17 +03:00
Danila Khodjaef 81ebad2120 Release: weekly deploy 2026-07-15 10:38:53 +03:00
Danila KhodjaefandCursor acc4a91a08 Add catalog search on timeline header and fix Back to Timeline navigation.
Public GET /api/search over artists, movements, and paintings with a debounced header bar on the timeline; Back to Timeline resets zoom and gallery session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 10:34:29 +03:00
Danila Khodjaef 50fc253ab2 Release: weekly deploy 2026-07-09 18:34:01 +03:00
Danila KhodjaefandCursor f78c14f307 Fix debug upload persistence and UX; exclude prod audit log from DB restore
Uploads and fixes now bust browser cache via file-mtime keys in API
responses. Debug upload shows a centered loading overlay and blocks search
while uploading. Prod DB restore skips curator_audit_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:30:19 +03:00
Danila Khodjaef 62096e8210 Release: weekly deploy 2026-07-09 16:03:48 +03:00
Danila Khodjaef 0ceb1c1c8c Release: weekly deploy 2026-07-09 13:36:33 +03:00
Danila Khodjaef f72ddcc3e4 Release: weekly deploy 2026-07-09 13:31:47 +03:00
Danila Khodjaef 3810e61bb8 Release: weekly deploy 2026-07-08 18:21:05 +03:00
Danila Khodjaef cdca271e08 Release: weekly deploy 2026-07-08 18:15:52 +03:00
Danila Khodjaef aa125ccb9b Release: weekly deploy 2026-07-08 18:06:19 +03:00
Danila KhodjaefandCursor 0de95fd151 Fix devtoprod git step exit-code handling
Add Invoke-Git with safe external command execution and stop using prod LAN URL for dev validateBuild checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:52:01 +03:00
Danila Khodjaef c391fcc21e Release: weekly deploy 2026-07-08 17:49:05 +03:00
Danila KhodjaefandCursor 3934cedc62 Fix devtoprod validateBuild exit-code handling
Ensure Invoke-Npm returns correct exit code without breaking step logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:46:56 +03:00
Danila KhodjaefandCursor 9e28f3db3f Fix validateBuild failing due to npm output capture
Ensure Invoke-Npm streams output to host and returns only exit code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:32:49 +03:00
266 changed files with 16782 additions and 1230 deletions
+2 -1
View File
@@ -16,8 +16,9 @@ TRUST_PROXY=true
IMAGE_DIR=./data/images
# Curator auth (run npm run dev:migrate after setting CURATOR_PASSWORD)
# Reset password anytime: npm run dev:reset-curator
SESSION_SECRET=change-me-to-a-long-random-string
SESSION_COOKIE_SECURE=false
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via proxy → Secure cookie). Set true/false to force.
CURATOR_USERNAME=curator
CURATOR_PASSWORD=
+12
View File
@@ -6,13 +6,25 @@ client/node_modules/
.env.*.local
infra/docker/.env.prod
infra/deploy/devtoprod.config.json
infra/deploy/harmonize.config.json
db/SyncReports/
infra/deploy/last-docker-release.json
# DB backups (may contain data)
db/DataBackup/
# Generated exports / scratch (CSV dumps, PDF text extracts)
Output/
__pycache__/
# Large local book PDFs (keep workbooks under Inputs/*.xlsx)
Inputs/*.pdf
# Recovery / temp files from local restore
_extracted/
_parse_*.js
tmp-*.js
tmp-*.py
# Logs
*.log
+290 -33
View File
@@ -17,6 +17,8 @@ All JSON responses use `Content-Type: application/json`. Errors return `{ "error
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
**Caching:** `/images` responses use `Cache-Control: public, max-age=0, must-revalidate` with `ETag` / `Last-Modified`. Painting and artist JSON payloads include optional **`image_cache_key`** / **`thumbnail_cache_key`** (and **`portrait_cache_key`** / **`portrait_thumb_cache_key`** on artists) — Unix ms from the files `mtime` on disk. The client appends `?v=<key>` to image URLs so fix/upload/clear updates show immediately after reload even when the relative path is unchanged.
**Quick check:**
```powershell
@@ -28,7 +30,7 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
## Authentication
Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes.
Anonymous visitors have implicit role **`user`** (browse only). Staff accounts in `users` are **`admin`** or **`curator`** with fine-grained **permissions**. Admins have all tools; curators only the flags assigned to them. Mutations are logged in `curator_audit_log` with `user_id`.
Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests.
@@ -40,19 +42,25 @@ Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials:
{ "role": "user" }
```
**Response (curator session)**
**Response (staff session)**
```json
{ "role": "curator", "username": "curator" }
{
"role": "admin",
"username": "curator",
"permissions": ["images", "checkup", "curator_notes", "translations", "influences", "tours", "users"]
}
```
`permissions` is the effective set (admins always receive the full list).
### `POST /api/auth/login`
**Body:** `{ "username": "curator", "password": "…" }`
**Response:** `{ "role": "curator", "username": "curator" }`
**Response:** same shape as `/me` for staff (`role`, `username`, `permissions`).
**Errors:** `401` invalid credentials, `400` missing fields.
**Errors:** `401` invalid credentials or disabled account, `400` missing fields.
### `POST /api/auth/logout`
@@ -60,29 +68,57 @@ Destroys the session cookie.
**Response:** `{ "ok": true }`
### Curator-only routes
### Permission flags
These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session:
| Permission | Gates |
|------------|--------|
| `images` | Debug image/portrait fix/clear/upload/delete, debug search/proxy |
| `checkup` | Checkup page + checkup flag patches |
| `curator_notes` | `PATCH …/curator-notes` |
| `translations` | `/api/translations/*` |
| `influences` | `/api/influences/*` |
| `tours` | Tour admin CRUD |
| `users` | `/api/users/*` (Users page) |
| Route | Audit action (mutations only) |
|-------|-------------------------------|
| `GET /api/paintings/checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
| `GET /api/debug/image-proxy` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
| `POST /api/paintings/:id/upload-image` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` |
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`.
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
### Users (admin / `users` permission)
Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)).
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/api/users` | List users + known permission keys |
| `POST` | `/api/users` | Create `{ username, password, role, permissions }` |
| `PATCH` | `/api/users/:id` | Update `role`, `permissions`, `is_active` |
| `POST` | `/api/users/:id/password` | Set new password; clears that users sessions |
Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`.
### Staff-gated routes
| Route | Permission | Audit action (mutations only) |
|-------|------------|-------------------------------|
| `GET /api/paintings/checkup` | `checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | `images` | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | `images` | — |
| `GET /api/debug/image-proxy` | `images` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `checkup` | `painting.checkup_flags` |
| `PATCH /api/paintings/:id/curator-notes` | `curator_notes` | `painting.update_curator_notes` |
| `PATCH /api/artists/:id/checkup-flags` | `checkup` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `images` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `images` | `painting.clear_image` |
| `POST /api/paintings/:id/upload-image` | `images` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `images` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `images` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `images` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
| `/api/translations/*` | `translations` | `translation.*` |
| `/api/influences/*` | `influences` | `influence.*` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/api/users/*` | `users` | `user.*` |
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)).
---
@@ -169,6 +205,147 @@ Movements are filtered to those with at least one artist active in the requested
---
## Locale (`?locale=ru`)
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
Full guide: [i18n-russian.md](i18n-russian.md).
---
## Translations (curator)
Requires curator session. Base path: `/api/translations`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
## Influences (curator)
Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) |
| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization |
| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` |
| `PATCH` | `/api/influences/:id` | Update notes / remap source |
| `DELETE` | `/api/influences/:id` | Delete edge |
| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` |
| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` |
| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }``409` if duplicate unless `force` |
Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged).
---
## Tours
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `GET` | `/api/tours` | public | Published tour summaries (`id`, `title`, `description`, cover thumb, `stopCount`) |
| `GET` | `/api/tours/:id` | public* | Full tour + ordered `paintings` + `stopBodies` (*draft tours require curator session) |
| `GET` | `/api/tours/admin` | curator | All tours (any status) |
| `POST` | `/api/tours` | curator | Create `{ title, description?, status? }` |
| `PATCH` | `/api/tours/:id` | curator | Update title / description / status / cover |
| `DELETE` | `/api/tours/:id` | curator | Delete tour + stops |
| `PUT` | `/api/tours/:id/stops` | curator | Replace ordered stops `{ stops: [{ paintingId, body }] }` |
Detail response shape:
```json
{
"tour": { "id": 1, "title": "…", "status": "published", "stopCount": 5 },
"paintings": [ /* Painting rows in stop order */ ],
"stopBodies": { "42": "Tour text for this stop…" }
}
```
---
## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
| `limit` | int | 20 | Max results total (capped at **50**) |
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
**Matching (case-insensitive `ILIKE`):**
| Entity | Fields |
|--------|--------|
| Artist | `name`, `wikipedia_title`, movement name |
| Movement | movement `name`, era name |
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
**Response**
```json
{
"q": "monet",
"results": [
{
"type": "artist",
"id": 19,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_name": "Impressionism",
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg"
},
{
"type": "movement",
"id": 12,
"name": "Impressionism",
"color": "#87CEEB",
"start_year": 1860,
"end_year": 1890,
"era_name": "Modern"
},
{
"type": "painting",
"id": 241,
"title": "Water Lilies",
"year": 1919,
"artist_id": 19,
"artist_name": "Claude Monet",
"movement_name": "Impressionism",
"thumbnail_path": "paintings/thumbs/Claude_Monet_Water_Lilies_thumb.jpg",
"image_path": "paintings/Claude_Monet_Water_Lilies.jpg"
}
]
}
```
**Indexes:** applied by `npm run dev:migrate` (`db/migrate-search.sql`) or standalone `npm run dev:migrate:search`.
**Client:** `api.search(q, { limit?, types? })`.
**Navigation from search:** choosing a **painting** opens detail with `returnTo: timeline`; the client shows **← Back to Timeline** and calls `goToTimelineHome()` (clears gallery session, resets timeline zoom). Choosing an **artist** or **movement** uses the normal gallery entry handlers.
---
## `GET /api/artists`
Artists for timeline portraits and the movement flow diagram.
@@ -196,9 +373,38 @@ Artists belonging to a single movement.
---
## `GET /api/movements/:id/artists-summary`
Lightweight artist list for the **movement gallery entry filter** modal (portraits + painting counts).
**Response** — array of:
```json
{
"id": 6,
"name": "Andrei Rublev",
"birth_year": 1360,
"death_year": 1430,
"portrait_path": "portraits/...",
"portrait_thumb_path": "portraits/thumbs/...",
"portrait_cache_key": 1710000000000,
"portrait_thumb_cache_key": 1710000000000,
"painting_count": 12
}
```
| Field | Meaning |
|-------|---------|
| `painting_count` | Number of paintings by this artist (any movement filter is by `artists.movement_id`) |
| `portrait_*_cache_key` | File mtimes for cache-busting (from `enrichArtistRow`) |
Ordered by `birth_year`, then name. Localized when `?lang=` / locale headers request a non-default locale.
---
## `GET /api/movements/:id/gallery`
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page, after the artist-filter modal).
**Response**
@@ -420,7 +626,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
**Public** — no curator login required.
Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
Fast local scan: links paintings to files already on disk and regenerates missing thumbs. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
**Response**
@@ -432,6 +638,20 @@ Fast local scan: links paintings to files already on disk. Does **not** download
---
## `POST /api/movements/:id/preload-images`
**Public** — no curator login required.
Same local-only scan as the artist preload, for every painting by artists in the movement. The 3D client calls this automatically when entering a **movement** hall.
**Response**
```json
{ "fetched": 40, "total": 45 }
```
---
## `GET /api/paintings/:id`
Painting detail with influence graph neighbours.
@@ -440,7 +660,19 @@ Painting detail with influence graph neighbours.
```json
{
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... },
"painting": {
"id": 10,
"title": "...",
"artist_name": "...",
"image_path": "paintings/Artist_Title.jpg",
"thumbnail_path": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"checkup_checked": false,
"checkup_fixed": false,
"curator_notes": "",
"has_influence_links": true
},
"influencedBy": [
{
"source_type": "painting",
@@ -544,6 +776,22 @@ Full-catalog audit table for the Checkup UI.
---
### `PATCH /api/paintings/:id/curator-notes`
Update public curator notes for a painting.
**Body**
```json
{ "curatorNotes": "Optional editorial text…" }
```
`curatorNotes` must be a string (trim applied; empty string clears the notes).
**Response:** `{ "curatorNotes": "…" }`
**Audit:** `painting.update_curator_notes`
### `PATCH /api/paintings/:id/checkup-flags`
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
@@ -579,6 +827,8 @@ Google-family image search for debug / checkup (Custom Search → Google Arts &
Download a remote URL and replace the paintings local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Invariant:** every painting picture write (fix, upload, fetch, disk sync) must regenerate a dedicated `paintings/thumbs/*_thumb.jpg` from the full file — never reuse a remote thumb URL or point `thumbnail_path` at the full image. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
**Body**
@@ -600,6 +850,8 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
{
"imagePath": "paintings/Artist_Title.jpg",
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"fixed": true,
"checked": true
}
@@ -660,7 +912,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes
}
```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `image_cache_key`, `thumbnail_cache_key`, `fixed`, `checked`).
---
@@ -705,19 +957,24 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `logoutCurator()` | `POST /api/auth/logout` |
| `api.getBounds()` | `GET /api/bounds` |
| `api.getTimeline(start, end)` | `GET /api/timeline` |
| `api.search(q, options?)` | `GET /api/search` |
| `api.getArtists(...)` | `GET /api/artists` |
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
| `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/<path>` or placeholder |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
| `preloadMovementImages(id)` | `POST /api/movements/:id/preload-images` |
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
| `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
| `paintingImageUrl(painting, sessionRevision?)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?, artist?)` | `/images/<path>` with `?v=` from revision or artist cache keys |
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.updatePaintingCuratorNotes(id, curatorNotes)` | `PATCH /api/paintings/:id/curator-notes` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
+44 -8
View File
@@ -104,7 +104,8 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
| `period_id` | FK → `artist_periods` | Optional grouping |
| `title` | VARCHAR(300) | |
| `year`, `year_end` | INTEGER | Creation date(s) |
| `description` | TEXT | |
| `description` | TEXT | Wikipedia-style catalog text |
| `curator_notes` | TEXT NOT NULL DEFAULT '' | Public curator editorial notes (inline edit on detail; brass plate in 3D hall when non-empty) |
| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** |
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
@@ -114,7 +115,7 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand.
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). `curator_notes` is applied by `db/migrate-curator-notes.sql` via `npm run dev:migrate`.
### `painting_annotations`
@@ -136,6 +137,34 @@ Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
Applied by `npm run dev:migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run dev:update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each works `wikipedia_title`).
### `tours` / `tour_stops`
Curated guided tours. See [tours.md](tours.md).
**`tours`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `title` | VARCHAR(200) | |
| `description` | TEXT | Default `''` |
| `status` | VARCHAR(20) | `draft` \| `published` |
| `cover_painting_id` | FK → `paintings` | ON DELETE SET NULL |
| `created_at` / `updated_at` | TIMESTAMPTZ | `updated_at` via trigger |
**`tour_stops`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `tour_id` | FK → `tours` | ON DELETE CASCADE |
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
| `sort_order` | INTEGER | Visitor / editor order |
| `body` | TEXT | English tour notes for the stop (v1) |
| `updated_at` | TIMESTAMPTZ | Trigger on UPDATE; required for `npm run harmonize:db` |
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`; `updated_at` also covered by `migrate-sync-timestamps.sql` when the table already exists).
### `painting_influences`
Directed edges: *this painting* was influenced by *that painting*.
@@ -186,34 +215,41 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
### `users`
Curator accounts (named logins). Anonymous site visitors do not have rows here.
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `username` | VARCHAR(64) UNIQUE | Login name |
| `password_hash` | VARCHAR(255) | bcrypt hash |
| `role` | VARCHAR(32) | `admin` or `curator` (`users_role_check`) |
| `permissions` | TEXT[] | Fine-grained flags for `curator` accounts; admins are treated as having all |
| `is_active` | BOOLEAN | Soft-disable; inactive users cannot log in |
| `created_at` | TIMESTAMPTZ | |
| `last_login_at` | TIMESTAMPTZ | Updated on successful login |
First curator is bootstrapped on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env.
**Permission keys:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`.
- **`admin`** — all curator tools + **Users** management (role bypasses permission checks).
- **`curator`** — only assigned permission flags.
- First account is bootstrapped as **admin** on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set. Manage additional accounts via the in-app **Users** page or `/api/users`.
### `curator_audit_log`
Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes).
Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, translations, influences, tours, user management).
| Column | Type | Notes |
|--------|------|-------|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → `users` | Who performed the action |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` |
| `resource_type` | VARCHAR(32) | `painting` or `artist` |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `user.create` |
| `resource_type` | VARCHAR(32) | `painting`, `artist`, `tour`, `user`, etc. |
| `resource_id` | INTEGER | Target row id |
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
| `created_at` | TIMESTAMPTZ | |
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`.
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `painting.update_curator_notes`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`, `tour.create`, `tour.update`, `tour.delete`, `tour.stops`, `user.create`, `user.update`, `user.reset_password`.
Example query in pgAdmin:
+31 -7
View File
@@ -23,7 +23,7 @@ Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-t
### Start — public dev (Keenetic URL)
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run dev:web
```
@@ -77,12 +77,13 @@ Stop-Process -Id <PID> -Force
## First-time install
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
copy .env.example .env # edit DB credentials, PUBLIC_URL
npm install
cd client; npm install; cd ..
npm run dev:migrate # schema + incremental SQL (+ auth tables, bootstrap curator)
npm run dev:reset-curator # upsert curator password from .env CURATOR_* (clears sessions)
npm run dev:setup # migrate + seed (fresh empty DB only)
```
@@ -94,14 +95,19 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup. Mutations are logged in `curator_audit_log` (view in pgAdmin).
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**). Mutations are logged in `curator_audit_log` per user (view in pgAdmin).
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
**Roles:**
| Role | Access |
|------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Above + debug mode, Checkup, image fix/upload/delete APIs |
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
| Admin | All curator tools + **Users** page to create accounts with individual passwords and permissions |
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts.
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
@@ -138,6 +144,10 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
| `npm run prod:db:backup` | Prod backup (reads `infra/docker/.env.prod`) |
| `npm run dev:db:restore -- --file <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) |
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
| `npm run dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
@@ -148,7 +158,7 @@ DB_NAME=gallery_dev
PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via Keenetic → Secure cookie)
CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
@@ -206,7 +216,9 @@ npm run dev:discover-influences # discovery only, no curated insert
| `npm run dev:find-duplicates` | Duplicate / near-duplicate painting rows |
| `npm run dev:audit-influence-duplicates` | Duplicate influence edges |
| `npm run dev:analyze-painter-palette` | CSV ↔ artist name match report |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` (gitignored folder) |
**Influence workbooks** (curator import): see [influence-import.md](influence-import.md) — e.g. `Inputs/gariff_influential_painters_influences.xlsx`, `Inputs/story_of_art_influences.xlsx`.
---
@@ -236,7 +248,18 @@ net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json
npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup)
npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy)
npm run prodto:dev:images # TrueNAS → dev repo
npm run prodto:dev:images # TrueNAS → dev repo (all images)
npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md
npm run harmonize:dry-run # preview DB + image changes only
```
**One artist only (prod → dev):** after `net use`, robocopy the artist file prefix (example: Duccio):
```powershell
$src = "\\192.168.10.122\Gallery\data\images\paintings"
$dst = "T:\Repo\Gallery\data\images\paintings"
robocopy $src $dst "Duccio*" /XO /R:2 /W:3
robocopy "$src\thumbs" "$dst\thumbs" "Duccio*" /XO /R:2 /W:3
```
Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **07** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
@@ -353,4 +376,5 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
| [setup.md](setup.md) | Install, env vars, troubleshooting |
| [data-and-images.md](data-and-images.md) | Catalog and image pipeline |
| [API.md](API.md) | REST endpoints |
| [tours.md](tours.md) | Guided tours |
| [DB_structure.md](DB_structure.md) | PostgreSQL schema |
+18
View File
@@ -0,0 +1,18 @@
this file contains draft for future releases and features
## Standing requirements (do not regress)
- **Painting thumbnails:** any create/replace/clear of a paintings full picture must regenerate or remove its dedicated `paintings/thumbs/` file — never use the full image or a remote thumb URL as `thumbnail_path`. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
- **Staff auth:** mutating curator tools must check session + permission flags (`admin` bypasses flags); actions must log to `curator_audit_log` with `user_id`. See [basics.md — User roles](basics.md#user-roles-and-access) and [API.md — Authentication](API.md#authentication).
## Feature backlog
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
+146 -54
View File
@@ -8,10 +8,12 @@ The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only.
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
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).
## Stack
@@ -45,8 +47,15 @@ Gallery/
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── pages/ToursPage.tsx # Guided tour editor
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
│ │ ├── i18n/ # react-i18next bootstrap
│ │ └── locales/{en,ru}/ # UI chrome strings
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
@@ -67,8 +76,8 @@ Gallery/
│ ├── painter-palette-lib.js
│ ├── import-painter-palette.js
│ └── image-fetcher.js
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
├── Output/ # Generated exports (e.g. paintings.csv)
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
@@ -117,9 +126,13 @@ Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deplo
```mermaid
flowchart TD
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
A -->|catalog search| S[Search results dropdown]
S -->|artist| C
S -->|movement| G
S -->|painting| D
A -->|click movement name| G[Movement gallery — 3D wings]
G -->|click painting| D
D -->|Back| G
D -->|Back to Gallery| G
G -->|back door / Wings / Exit| H[Wing navigator]
H -->|pick wing| G
H -->|Exit to Timeline| A
@@ -131,16 +144,28 @@ flowchart TD
D -->|prev / next| D
D -->|click centre image| F[Fullscreen lightbox]
F -->|close| D
D -->|Back| C
D -->|Back to Gallery| C
C -->|exit doorway / E key| E[Path picker]
E -->|predecessors| C
E -->|successors| C
D -->|influence thumbnail| D
D -->|artist link| B
B -->|Back| A
C -->|Back| A
B -->|Back| C
C -->|Back to Timeline| A
D -->|Back to Timeline when opened from search| A
```
### Back navigation
| Control | Behaviour |
|---------|-----------|
| **← Back to Timeline** (3D gallery header, movement **Exit to Timeline**) | Always returns to the **home timeline**: unmounts the hall, clears the gallery session, resets timeline zoom to the full catalog year range |
| **← Back to Gallery** (painting detail from a hall) | Returns to the **same hall session** — camera position and wing are preserved |
| **← Back to Timeline** (painting detail opened from catalog search) | Returns to the home timeline (same as gallery **Back to Timeline**) |
| **← Back** (artist bio) | Returns to wherever you opened bio from (usually the artist hall) |
Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browser **Back** button; it is not wired to app navigation.
## Timeline and movement flow
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
@@ -152,6 +177,22 @@ The home page shows two linked views over the **same year window** (`viewStart`
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
### Catalog search (timeline header)
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
| UX | Detail |
|----|--------|
| Minimum query | 2 characters after trim |
| Debounce | 300 ms |
| Result groups | Artists, Movements, Paintings (with thumb or movement colour swatch) |
| Open artist | Preload images → artist 3D hall |
| Open movement | Movement gallery (wing 1) |
| Open painting | Painting detail with `returnTo: timeline`**Back to Timeline** |
| Keyboard | `↑`/`↓` highlight, `Enter` open, `Escape` close |
Run `npm run dev:migrate:search` once on existing databases before first use, or rely on `npm run dev:migrate` (includes `migrate-search.sql`). See [API.md — GET /api/search](API.md#get-apsearch).
### Timeline data loading
On first visit, `HomePage.tsx` fetches the full catalog once:
@@ -168,9 +209,9 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view
|--------|------|
| Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes |
| Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) |
| Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) |
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
| Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes and door/hall shaders warm up after the hall opens (HDR Environment and painting textures continue in the background) |
| Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame |
| Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts |
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx``handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
@@ -200,16 +241,17 @@ Major world events appear on the era bar as pin markers (single years) or shaded
### Movement flow
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** from its start year to its end year.
| Feature | Implementation |
|---------|----------------|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap |
| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parents length) |
| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform |
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height |
### Artists on movement streams
@@ -230,9 +272,9 @@ Each artist appears as a **portrait circle** on their movements stream row:
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
| Drag on flow canvas | Pan |
| Click portrait | Open artist biography |
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
| Click **movement name** (label on stream) | Open the **artist filter** modal for that movement, then the movement gallery |
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop.
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artists lifespan on the era bar and brightens their segment on the movement stream.
@@ -240,7 +282,9 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
## Virtual gallery (3D halls)
The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological).
The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
**Shared wall hang (all modes):** visit order is a **U-shape****left wall**, then the **far/end wall** ahead when entering, then the **right wall**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. With fewer than three works, only left/right are used. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
### Artist halls
@@ -250,16 +294,17 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
| Paintings on walls | Works hang on the **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large |
| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor |
| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
| Paintings on walls | Works hang on **left**, **far/end**, and **right** walls in **one row per wall**; room **depth** and **width** grow with the catalog |
| Wall order | Chronological U-path: first third **left** (entrance → end), middle third **end wall**, last third **right** (end → entrance) |
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
@@ -281,25 +326,26 @@ Predecessors and successors come from **`painting_influence_sources`** (painting
### Movement galleries
Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx``GET /api/movements/:id/gallery`).
Enter from the home page by clicking a **movement name** on the movement flow. First a centered **artist filter** modal (`ArtistFilterModal.tsx`) loads `GET /api/movements/:id/artists-summary` (portrait, lifespan, painting count). All artists are selected by default; deselect any to exclude their works, then **Enter gallery**. The client loads `GET /api/movements/:id/gallery` and filters paintings to the selected artist IDs before opening `VirtualGallery`.
| Rule | Implementation |
|------|----------------|
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
| One gallery per movement | Paintings by **selected** artists in that movement, sorted chronologically |
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) |
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
| Frame captions | **Year · artist** label below each frame |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
| Back wall | **Exit double doors****Wing navigator** (jump to any wing) or **Exit to Timeline** |
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true |
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) |
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors****Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
| Missing images | Draped canvas cover in frame |
| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
**Controls (movement gallery):**
@@ -307,24 +353,50 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|-------|--------|
| Walk / turn / drag | Same as artist hall |
| Click painting | Open detail view (returns to the same wing) |
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
| Front archway / `E` (when near, multi-wing) | Advance to the **next chronological wing** |
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
### Guided tour halls
Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses the movement winged hall (`mode: 'tour'` in `VirtualGallery.tsx`).
| Rule | Implementation |
|------|----------------|
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
| Wings | Same ~55-per-wing split as movements; order preserved across wings |
| Wall hang | Same U-shaped hang as other halls (left → end → right) |
| Detail | Tour stop text panel; walks tour stops |
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
Full guide: [tours.md](tours.md).
### Shared 3D behaviour
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
## Painting detail view
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another works detail page.
Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another works detail page.
| Layer | What you see |
|-------|----------------|
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
| **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) |
| **Curator notes** | Public editorial text on the painting (`paintings.curator_notes`); visitors see it when non-empty; curators edit inline on the detail page |
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
@@ -332,17 +404,18 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
| Input | Action |
|-------|--------|
| `` / `` beside image | Previous / next work by the **same artist** (chronological order) |
| `` / `` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** |
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
| Click centre image | Open fullscreen lightbox |
| Click influence thumbnail | Open that works detail (different artist allowed) |
| Click influence artist portrait | Open that artists 3D gallery hall |
| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
| **← Back to Gallery** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
| **← Back to Timeline** | Return to the home timeline (from search result, or from the 3D gallery header / movement **Exit to Timeline**) — hall unmounts, timeline zoom resets |
| **About {artist}** | Open artist biography |
**Navigation rules:**
- **Catalog browsing** ( / arrow keys) walks the current artists works earliest → latest. It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
- **Catalog browsing** ( / arrow keys) walks the active catalog (artist / movement chronology, or tour stops). It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
- Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background).
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
@@ -364,11 +437,13 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **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.
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
- **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. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
- **3D gallery images** use local **thumbnails** for hall frames (not multi-MB originals). `POST /api/artists/:id/preload-images` / `POST /api/movements/:id/preload-images` run in the background when entering a hall (public routes — link disk files and regenerate missing thumbs).
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## User roles and access
@@ -376,23 +451,34 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do |
|------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, debug API mutations |
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
| **`admin`** | Named staff account | All curator tools + **Users** management |
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
Staff sign in via **Curator login** in the site header (individual username/password). Sessions use an HTTP-only cookie (`gallery.sid`). The UI shows only tools the account may use; the server enforces the same rules (`401` without a session, `403` without permission).
Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`).
Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
## Developer tools (image audit)
Curator-only workflow for reviewing and fixing local image files — not part of the public visitor experience.
Staff workflow for reviewing and fixing local image files (requires **`images`** permission) — not part of the public visitor experience.
| Feature | Where | Purpose |
|---------|--------|---------|
| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools |
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Logout** | Home header (curators) | Ends session; hides debug tools |
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools |
| **Debug mode** | Home header toggle (`images`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (`images`, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (`translations`) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
| **Logout** | Home header (staff) | Ends session; hides staff tools |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
### Debug panel (painting detail and artist bio)
@@ -405,11 +491,13 @@ When debug mode is on, a panel at the bottom-left shows the image search query,
| **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) |
| **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same |
| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
| **Upload** | Local file picker → full image + **auto-generated painting thumb** | Local file → portrait + **auto-generated portrait thumb** |
| **Upload** | Local file picker (`DebugUploadButton`) → full image + **auto-generated painting thumb**; full-page **Loading…** overlay; hides current image and pauses search/fix until upload finishes | Local file → portrait + **auto-generated portrait thumb**; same upload overlay behaviour |
| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — |
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
Pressing **Upload** clears the debug search preview and closes **More** before the file picker opens. While uploading, **Fix it**, **More**, and **Checked** are disabled and the main painting/portrait is hidden behind a centered loading overlay.
Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot.
Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible.
@@ -432,6 +520,10 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
|----------|----------|
| [setup.md](setup.md) | Install, database, npm scripts |
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Release runbook + one-command `devtoprod:release` |
| [harmonize-dev-prod.md](harmonize-dev-prod.md) | Incremental dev ↔ prod merge (catalog DB + images) |
| [DB_structure.md](DB_structure.md) | Tables and relationships |
| [API.md](API.md) | REST endpoints |
| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph |
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
+46 -14
View File
@@ -7,6 +7,21 @@ How catalog content, biographies, and artwork files enter the system.
1. **No runtime hot-linking** — the UI reads from `/images/…` (local disk). External URLs are used only during ingest.
2. **No AI-generated art or text** — biographies and descriptions come from Wikipedia; influence notes from curated art-history sources.
3. **Local copies** — every displayed image should exist under `data/images/` after seeding or fetch.
4. **Painting thumbnails must stay in sync with the full image** — see [Painting thumbnail invariant](#painting-thumbnail-invariant) below. Treat this as a hard requirement for any new feature or script that writes or replaces painting picture files.
## Painting thumbnail invariant
**Requirement (do not regress):** any action that **creates, replaces, or clears** a paintings full picture must also **regenerate or remove** that paintings dedicated thumbnail under `paintings/thumbs/`. Never point `thumbnail_path` at the full-size file as a stand-in, and never reuse a search-result or Commons thumb URL.
| Change type | Expected thumb behavior | Primary code |
|-------------|-------------------------|--------------|
| Fix / upload / buffer or URL replace | Write full file, then `writePaintingThumb` / `generateThumbnailFromFull` | `server/image-service.js` |
| Fetch / seed / expand / influence ingest | Same: thumb from saved full via `savePaintingImages` | `scripts/image-fetcher.js` |
| Disk sync / preload / ensure when full exists but thumb missing | Generate real `*_thumb.jpg` and update `thumbnail_path` | `ensurePaintingThumbFromFull`, `syncPaintingFromDisk`, `ensurePaintingImages`, `preloadArtistImagesLocal`, `preloadMovementImagesLocal` |
| Clear image / delete painting | Delete thumb file(s) and null paths (or drop row) | `clearPaintingImage`, `deletePainting` |
| Bulk rebuild after sync/harmonize | `regenerate-thumbnails.js` / `harmonize:images` | scripts + npm scripts |
Path-only maintenance (`sync-image-paths.js`) may link existing thumb files without rewriting pixels; if full images changed without a matching thumb step, run `npm run dev:regenerate-thumbnails` (or `harmonize:images`).
## Directory layout
@@ -31,7 +46,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| **Development** | `./data/images/` in repo | Working copy on dev PC |
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
Promote dev → prod files: `npm run devtoprod:images` (after `net use \\192.168.10.122\Gallery`). Refresh dev from prod: `npm run prodto:dev:images`. See [environments.md](environments.md).
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artists `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then merge artists/paintings checkup flags + image paths, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
## Scripts overview
@@ -49,6 +64,8 @@ Promote dev → prod files: `npm run devtoprod:images` (after `net use \\192.168
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step also merges artists/paintings checkup flags + paths, then rebuilds thumbs on both sides |
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
| `audit-painting-images.js` | `npm run dev:audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
@@ -181,7 +198,7 @@ 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 (from painting and artist sources)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses); rendered upside down in `InfluencePictureLamp`
### Audit duplicate influence links
@@ -261,9 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend)
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames.
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline)
@@ -280,9 +297,22 @@ Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement)
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
## Curator notes (painting editorial text)
Public notes authored by curators on a painting — separate from Wikipedia `description`, tour stop text, and art-history annotations.
| Aspect | Detail |
|--------|--------|
| Storage | `paintings.curator_notes` (`TEXT NOT NULL DEFAULT ''`) |
| Migration | `db/migrate-curator-notes.sql` via `npm run dev:migrate` |
| UI | Panel on painting detail (after tour notes, before description); curators edit inline when signed in |
| Hall marker | Small brass plate beneath the frame in the 3D hall when notes are non-empty |
| API | Included on `GET /api/paintings/:id` (`p.*`); update via `PATCH /api/paintings/:id/curator-notes` (curator) |
| i18n | Canonical English only for now (not in the translations pipeline) |
## Painting annotations (art-history notes)
Short curator-style notes on the painting detail page — separate from the influence graph.
Short art-history citations on the painting detail page — separate from the influence graph and from `curator_notes`.
| Aspect | Detail |
|--------|--------|
@@ -349,17 +379,17 @@ When a painting has no local file, `GET /api/paintings/:id/image` triggers `ensu
- **Wikipedia search** when catalog labels fail
- Wikidata → Wikimedia Commons → Wikipedia page image
- Fallbacks: Met Museum, Art Institute of Chicago, Cleveland Museum, Rijksmuseum, Smithsonian*, Harvard*
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file.
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file. If a full file already exists but a dedicated thumb under `paintings/thumbs/` is missing, regenerate the thumb (`ensurePaintingThumbFromFull`) rather than serving the full image as a thumb.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`. See [Painting thumbnail invariant](#painting-thumbnail-invariant).
Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-demand resolution uses a ~2.5 s delay between external requests to reduce rate-limit risk; batch `fetch-images` runs skip that delay while the per-painting deadline is active.
## Preload before 3D gallery
`POST /api/artists/:id/preload-images` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artists** 3D hall so textures use files already on disk.
`POST /api/artists/:id/preload-images` and `POST /api/movements/:id/preload-images` are **public** routes (no curator login). They run **local-only** linking — no network — and regenerate missing `*_thumb.jpg` files from full images on disk. The React client calls them automatically when entering an artist or movement 3D hall so textures use thumbs already on disk.
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
Works without files still show the canvas cover in the frame.
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
@@ -419,13 +449,15 @@ Re-run `import-painter-palette` after adding gallery artists or updating the CSV
## Catalog export
Export the full painting catalog as CSV:
Export helpers write under **`Output/`** (gitignored — regenerate as needed):
```bash
npm run dev:export-paintings
```
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes.
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title).
Ad-hoc **prod** dumps (movements / artists / paintings) can also be written to `Output/*.csv` for offline review. Influence workbooks ready for the curator import wizard belong under **`Inputs/`** (e.g. Gariff, Story of Art) — see [influence-import.md](influence-import.md).
## Image fetcher overrides
@@ -481,7 +513,7 @@ When **Debug mode** is on (home header) or from the **Checkup** page:
2. **More**`GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`.
3. **Fix**`POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix``replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`. The server **always regenerates thumbnails from the saved full image** (`writePaintingThumb` / `writePortraitThumb` via `sharp` — not the search-result thumb URL), updates `thumbnail_path` / `portrait_thumb_path`, and sets `checkup_fixed` + `checkup_checked`.
4. **Clear**`POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings).
5. **Upload**`POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**.
5. **Upload**`POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**. The file picker uses a native `<label>` + hidden `<input>` (`DebugUploadButton.tsx`) so the `change` event is reliable on Windows/Chromium.
6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog.
### Debug panel (painting detail and artist bio)
@@ -494,10 +526,10 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline |
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) |
| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI |
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save full image + **auto-generated thumb** |
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker (`DebugUploadButton`) → save full image + **auto-generated thumb**; centered **Loading…** overlay; clears preview and blocks search/fix while uploading |
| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation |
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged.
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session. Image URLs include `?v=<mtime_ms>` from API **`image_cache_key`** / **`thumbnail_cache_key`** (file `mtime` on disk) so replaced files reload after a full page refresh even when the relative path is unchanged. In-session counters still bump immediately after a mutation.
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
+18 -10
View File
@@ -2,7 +2,11 @@
Step-by-step guide for promoting the **development** version of Gallery to **production**. Covers code, database schema, database data, thumbnail generation, and image files.
> **Default is dev.** This runbook is for a **scheduled release** (~weekly, or when you explicitly decide to ship). Day-to-day work stays on dev — see [environments.md](environments.md#development-first-workflow-default). If the one-time prod install is not done yet, follow [environments.md → One-time setup](environments.md#one-time-setup-full-walkthrough) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) first.
> **Default is dev.** This runbook is for a **scheduled release** (~weekly, or when you explicitly decide to ship). Day-to-day work stays on dev — see [environments.md](environments.md#development-first-workflow-default).
>
> **Mid-week merge:** If both dev and prod have catalog or image edits and you need **last-write-wins** sync instead of a full prod overwrite, use [harmonize-dev-prod.md](harmonize-dev-prod.md) (`npm run harmonize`). Keep this runbook for releases where prod should exactly match dev.
>
> If the one-time prod install is not done yet, follow [environments.md → One-time setup](environments.md#one-time-setup-full-walkthrough) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) first.
| | Dev (source) | Prod (target) |
|---|---|---|
@@ -12,7 +16,7 @@ Step-by-step guide for promoting the **development** version of Gallery to **pro
| URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro |
| Env file | root [`.env`](../.env) (`gallery_dev`) | [`infra/docker/.env.prod`](../infra/docker/.env.prod) (`gallery_prod`) |
All commands run on the **dev PC** from the repo root (`C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery`) unless a step says **TrueNAS**.
All commands run on the **dev PC** from the repo root (`T:\Repo\Gallery`) unless a step says **TrueNAS**.
---
@@ -30,13 +34,15 @@ For a typical weekly release, use the orchestrator instead of running each step
2. Edit [`infra/deploy/devtoprod.config.json`](../infra/deploy/devtoprod.config.json) (gitignored — safe for SMB password):
- `profile`: `full` | `code` | `data` (base step set; individual `steps` overrides win)
- `steps` — per-step overrides; see table below. On **`full`** profile, `migrateProdSchema` is **on by default** (runs `dev:migrate` on `gallery_prod` before restore).
- `autoConfirm`: `true` skips restore/image-sync prompts (`CONFIRM_PROD=1`, `-SkipConfirm`)
- `autoStartDevStack`: `true` starts `dev:server` (or `dev:web`) automatically when `validateBuild` finds API down
- `git.message`, `git.branch` — used when `gitCommitPush` is enabled
- `schemaChanged`: `true` enables prod schema migration on `full` profile
- `smb.user` / `smb.password` — optional; maps `\\host\share` before image sync
- `backupFile` — optional fixed path; otherwise uses the newest `gallery_dev_data_*.txt` after backup
3. Prerequisites still apply: Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present.
3. Prerequisites still apply: **`npm run dev:web` running** (Vite `:5173` + API `:3451`), Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present.
- Before migration/restore steps, verify prod DB target in `.env.prod`: `DB_NAME=gallery_prod`.
### Run
@@ -56,15 +62,15 @@ npm run devtoprod:release -- -DryRun
| Config step | Maps to runbook |
|-------------|-----------------|
| `validateBuild` | Step 0 — `prod:build` + dev `/api/bounds` check |
| `validateBuild` | Step 0 — `prod:build` + dev `/api/bounds` check (API `:3451` and/or Vite `:5173` proxy) |
| `gitCommitPush` | Step 1 — `git add`, commit, push |
| `thumbnails` | Step 2 — `devtoprod:thumbnails` |
| `backupDev` | Step 3 — `dev:db:backup` |
| `backupProd` | Step 3 (rollback) — `prod:db:backup` |
| `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` |
| `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` (enabled on `full` profile; auto-runs before `restoreProd` if you disabled it but left restore on) |
| `restoreProd` | Step 5 — `devtoprod:db:restore` |
| `syncImages` | Step 6 — `devtoprod:images` |
| `dockerPublish` | Step 7 — `prod:docker:publish` |
| `dockerPublish` | Step 7 — `prod:docker:publish` (auto tag: `YYYYMMDD-HHMMSS-<gitsha>` + push `latest`) |
| `truenasRestartPause` | Step 8 — **manual** pause; script waits for Enter after you restart **gallery-web** |
| `verify` | Step 9 — curl LAN + public `/api/bounds` |
@@ -84,13 +90,13 @@ Use the manual steps below when you need a **partial** release or want to inspec
## What changed → which steps to run
Run only the steps that match what you changed. Steps are independent except that **code** needs a rebuilt image and **schema changes** must be applied before a **data** restore.
Run only the steps that match what you changed. Steps are independent except that **code** needs a rebuilt image and **prod schema must match the dev backup** before a data restore (Step 4 before Step 5).
| You changed… | Required steps |
|--------------|----------------|
| Application code (client/server) | 0 → 1 → 7 → 8 → 9 |
| DB schema (new `db/migrate-*.sql`, `schema.sql`) | 0 → 1 → 3 → **4** → (5 if data too) → 8 → 9 |
| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 3 → 5 → 9 |
| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 3 → **4** → 5 → 9 |
| Image files (new/replaced paintings, portraits) | 0 → 2 → 3 → 5 → 6 → 9 |
| Thumbnails only (rebuild from existing full images) | 0 → 2 → 3 → 5 → 6 → 9 |
| Everything (typical weekly release) | 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 |
@@ -240,7 +246,7 @@ Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole
**Caveats — prod tables are replaced by dev's contents:**
- `users` and `curator_audit_log` are overwritten. The **dev curator account and password become the prod login**, and prod audit history is replaced. Make sure the dev curator credentials are the ones you want in prod.
- `users` is overwritten. The **dev curator account and password become the prod login**. `curator_audit_log` is **not** synced — prod keeps its existing audit history.
- The `session` table is truncated, so any active prod curator sessions are logged out.
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
@@ -358,6 +364,7 @@ Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data ro
- Prod DB scripts read **only** `infra/docker/.env.prod`; dev scripts refuse `_prod` database names.
- Prod restore/backup require typing `yes` (or `CONFIRM_PROD=1` / `autoConfirm` in release config).
- `npm run dev:migrate` targets whatever `DB_NAME` is set — always `Remove-Item Env:\DB_NAME` after Step 4 so later commands stay on dev.
- **`restoreProd` safety:** if `migrateProdSchema` is disabled in config but `restoreProd` is enabled, the orchestrator runs Step 4 first so prod has columns/tables present in the dev backup (e.g. `updated_at`, `entity_translations`).
- Deploy-related npm scripts and PowerShell helpers print a final **`===== SUCCESS: … =====`** or **`===== FAILED: … =====`** banner as the last line of output.
## Troubleshooting
@@ -367,6 +374,7 @@ Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data ro
| `/api/bounds` returns `{"min_year":null,"max_year":null}` on prod | `gallery_prod` has schema but no data | Run Step 5 (restore); confirm rows with a count query on `art_movements` / `paintings` |
| `robocopy ... ERROR 5 (0x00000005) Access is denied` on Step 6 | SMB share not mapped/authenticated in this Windows session | `net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER`, verify `Test-Path`, then re-run. If still denied after auth, fix TrueNAS dataset ACL (`chown -R 1001:1001`, grant the SMB user write) |
| Restore: `permission denied to set parameter "session_replication_role"` | DB role is not superuser | Handled automatically (multi-pass insert). For the fast path, superuser runs `GRANT SET ON PARAMETER session_replication_role TO gallery;` |
| Restore: `column "updated_at" of relation … does not exist` | Prod schema behind dev backup | Run Step 4 before Step 5; `full` profile enables `migrateProdSchema` by default; partial config with `restoreProd` alone also auto-migrates |
| Restore: `unterminated quoted string` | Old parser split multi-line values (bios) | Fixed — statements are accumulated until quotes balance; update to latest `scripts/restore-db-data.js` |
| `git push` → `Failed to authenticate user` (Gitea) | Git Credential Manager cached an expired token | Clear it: `"protocol=https`nhost=gitea.mysuperlab.netcraze.pro`n" \| git credential reject`, then push again to re-prompt |
| Prod tables empty after a failed restore | Restore truncates **before** inserting; a mid-run error leaves tables empty | Dev is untouched — just re-run Step 5 |
+19 -9
View File
@@ -7,6 +7,11 @@ Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two
| **Development** | `gallery_dev` | `https://devgallery.mysuperlab.netcraze.pro` | Dev PC `192.168.10.70:5173` |
| **Production** | `gallery_prod` | `https://gallery.mysuperlab.netcraze.pro` | TrueNAS container `192.168.10.122:5173` |
Version tracking:
- Runtime version endpoint: `/api/version` (reports `app_env`, `app_version`, `image_tag`, `git_sha`, `built_at`, `db_name`)
- `npm run prod:docker:publish` auto-creates a release tag like `20260709-155412-f72ddcc` (build time + git commit), pushes it and `latest`
- Version metadata is baked into the Docker image; TrueNAS only needs `gallery-web:latest` with `pull_policy: always`
---
## Development-first workflow (default)
@@ -85,14 +90,14 @@ If STEP 2 fails with “database already exists”, STEP 0 likely already shows
**Alternative (dev PC with psql installed):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
```
**Alternative (Node, postgres password in env):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD"
npm run infra:db:split-dev-prod
```
@@ -108,17 +113,16 @@ npm run infra:db:split-dev-prod
PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
`npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty.
Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page.
2. Run:
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run dev:migrate
npm run dev:web
```
@@ -151,7 +155,7 @@ Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on Tru
# Map share (use your TrueNAS SMB user/password)
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run devtoprod:images
```
@@ -168,7 +172,7 @@ If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in
Prerequisites: **Docker Desktop running**, logged in to Gitea.
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
docker login gitea.mysuperlab.netcraze.pro
npm run prod:docker:publish
```
@@ -249,10 +253,9 @@ Expect **HTTP 200** (not 502).
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
```
Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
Omit `SESSION_COOKIE_SECURE` for auto Secure cookies behind Keenetic HTTPS. Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
Restart `npm run dev:web` after changing `PUBLIC_URL`.
@@ -300,6 +303,7 @@ See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documen
| Fast local HMR (no Keenetic) | `npm run dev:server` + `npm run dev:client` |
| Refresh dev DB from prod | `npm run prodto:dev:db` |
| Pull prod images to dev | `npm run prodto:dev:images` |
| Merge dev ↔ prod catalog + images (incremental) | `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md) |
---
@@ -307,6 +311,8 @@ See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documen
Run this when you are ready to ship dev to production — **not** after every small change. Typical cadence: **about once a week**.
For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-prod.md](harmonize-dev-prod.md) instead of full restore.
**One command (recommended):** copy [`infra/deploy/devtoprod.config.example.json`](../infra/deploy/devtoprod.config.example.json) to `infra/deploy/devtoprod.config.json`, edit it, then `npm run devtoprod:release` or `deploy-dev-to-prod.cmd`. See [deploy-dev-to-prod.md → One-command release](deploy-dev-to-prod.md#one-command-release-automated).
**Manual steps:** detailed runbook (per-change decision matrix, schema migration, rollback): [deploy-dev-to-prod.md](deploy-dev-to-prod.md).
@@ -333,11 +339,15 @@ Run this when you are ready to ship dev to production — **not** after every sm
| `npm run prodto:dev:db` | Dev PC PowerShell | Clone prod → dev |
| `npm run dev:db:backup` | Dev PC PowerShell | Dev backup |
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + artists/paintings checkup/path sync + regenerate thumbs on both sides |
## Image sync
| Command | Where | Direction |
|---------|-------|-----------|
| `npm run harmonize` | Dev PC PowerShell | Bidirectional merge (mtime newer wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run devtoprod:release` | Dev PC PowerShell | Full config-driven promote (see [deploy-dev-to-prod.md](deploy-dev-to-prod.md#one-command-release-automated)) |
| `npm run devtoprod:thumbnails` | Dev PC PowerShell | Rebuild painting + portrait thumbs on dev before promote |
| `npm run devtoprod:images` | Dev PC PowerShell | Dev → TrueNAS volume |
+191
View File
@@ -0,0 +1,191 @@
# Harmonize dev and prod (incremental merge)
Bidirectional **catalog data** and **image** sync between `gallery_dev` and `gallery_prod`, with **last-write-wins** by timestamp. Use this when **both** environments may have curator edits since the last release — not when prod should become an exact copy of dev.
For a full prod replace (weekly release), use [deploy-dev-to-prod.md](deploy-dev-to-prod.md) (`npm run devtoprod:release`).
For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive to dev).
---
## Rules
| Layer | Direction | Conflict resolution |
|-------|-----------|---------------------|
| **Schema** | dev → prod only | Run `npm run harmonize:schema` (same as `dev:migrate` on `gallery_prod`) |
| **Catalog DB** | dev ↔ prod | Newer `updated_at` wins; missing rows copied to the other side (union merge) |
| **Images** | dev ↔ prod | Newer file mtime wins; missing files copied to the other side |
| **Users / sessions / audit** | not synced | `users`, `session`, `curator_audit_log` stay env-local |
**Never auto-deletes** rows or files that exist on only one side.
---
## Prerequisites
1. Both databases on Postgres `192.168.10.122` with `updated_at` columns applied:
```powershell
npm run dev:migrate
$env:DB_NAME = "gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME
```
2. Backfill `updated_at` from image file mtimes (recommended once after migration):
```powershell
npm run dev:backfill-updated-at
npm run harmonize:backfill-updated-at
```
3. [`infra/docker/.env.prod`](../infra/docker/.env.prod) present with `DB_NAME=gallery_prod`.
4. SMB share reachable for prod images:
```powershell
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
```
5. Copy **dedicated** harmonize config (do not rely on `devtoprod.config.json` alone — that file is a release profile and its step flags are ignored for harmonize):
```powershell
Copy-Item infra/deploy/harmonize.config.example.json infra/deploy/harmonize.config.json
```
Edit `harmonize.config.json` (gitignored) — optional `smb.user` / `smb.password`, `prefer` for tie-breaks (`dev` | `prod`), `schemaChanged: true` when new migrations shipped.
6. Prod DB targeting uses [`infra/docker/.env.prod`](../infra/docker/.env.prod). `scripts/db-env.js` loads that file with **file values winning** over the root `.env`, so `harmonize:db` can open `gallery_dev` and `gallery_prod` in the same process without the safety check rejecting `gallery_dev` as a fake prod target.
---
## One-command harmonize
```powershell
npm run harmonize
```
Dry-run (report only, no writes):
```powershell
npm run harmonize -- -DryRun
```
Or:
```powershell
npm run harmonize:dry-run
```
### Orchestrator steps
| Step | npm script | Purpose |
|------|------------|---------|
| `backupDev` | `dev:db:backup` | Safety snapshot |
| `backupProd` | `prod:db:backup` | Safety snapshot |
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
| `db` | `harmonize:db` | Row-level catalog merge |
| `images` | `harmonize:images` | Bidirectional file merge, then merge **artists/paintings** (checkup flags + image paths), then regenerate thumbs on **dev and prod** |
| `verify` | curl `/api/bounds` | Optional smoke check |
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
---
## Individual commands
| Command | Purpose |
|---------|---------|
| `npm run harmonize:schema` | Migrate prod schema from dev migration files |
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
| `npm run harmonize:images` | Merge image files by mtime, merge artists/paintings checkup flags + image paths, then regenerate thumbs on both sides |
| `npm run harmonize:images -- --skip-thumbnails` | File + checkup/path DB merge only (no thumb rebuild) |
| `npm run harmonize:images -- --skip-db` | File merge (+ thumbs) without artists/paintings checkup sync |
| `npm run harmonize:images -- --dry-run` | Preview file copies and catalog merge (skips thumbnails) |
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
---
## Catalog tables synced
Processed in FK order:
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`** → **`tours`** → **`tour_stops`**
Every synced table needs an `updated_at` column (including `tour_stops` — added via `migrate-tours.sql` / `migrate-sync-timestamps.sql`).
---
## Conflict handling
Harmonize reports conflicts in the JSON report and skips those rows:
| Conflict | Cause | Resolution |
|----------|-------|------------|
| `id_collision` | Same `id` but different natural key (e.g. artist name) | Manual fix in pgAdmin; environments diverged too far |
| `equal_updated_at` | Same timestamp, different row content | Re-run with `--prefer=dev` or `--prefer=prod`, or edit one side and re-run |
**Tip:** Harmonize regularly from a shared baseline (e.g. after each weekly release) to avoid ID/natural-key collisions from independent inserts.
---
## When to use what
| Situation | Tool |
|-----------|------|
| Weekly release — prod should match dev exactly | `npm run devtoprod:release` |
| Mid-week prod curator fix + dev also changed | `npm run harmonize` |
| Dev workspace stale — full prod copy | `npm run prodto:dev:db` |
| New migration in repo | `harmonize:schema` or deploy step 4 |
| Only images changed on one side | `npm run harmonize:images` |
| Only DB metadata changed | `npm run harmonize:db` |
| Pull **one artists** images from prod → dev | Map SMB, then robocopy `Duccio*` (or the artist prefix) under `paintings/` and `paintings/thumbs/` — see [FAC.md — Dev ↔ prod image sync](FAC.md#dev--prod-image-sync-smb) |
---
## Example flows
### Prod curator uploaded a painting; dev also edited metadata
```powershell
npm run harmonize
```
DB rows merge by `updated_at`; image files merge by mtime. Both sides receive the latest version of each entity.
### Schema change + mixed edits
1. Finish and test on dev: `npm run dev:migrate`
2. Set `schemaChanged: true` in `harmonize.config.json` (or enable `steps.schema`)
3. `npm run harmonize`
### Preview before writing
```powershell
npm run harmonize:dry-run
# Review db/SyncReports/harmonize_*.json
npm run harmonize
```
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `Refusing prod operation on database "gallery_dev"` | Old `db-env` left `DB_NAME` from root `.env` when loading prod | Update to current `scripts/db-env.js` (`.env.prod` wins); confirm `infra/docker/.env.prod` has `DB_NAME=gallery_prod` |
| `Table tour_stops missing updated_at` | Prod/dev schema behind | `npm run dev:migrate` and migrate prod (`$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME`) |
| Orchestrator lists release steps (`restoreProd`, …) | Missing `harmonize.config.json`; fallback to release config | Copy `harmonize.config.example.json` → `harmonize.config.json` (current `harmonize.ps1` ignores release-only step keys) |
---
## Safety
- Pre-flight backups of dev and prod DB (configurable; on by default)
- Prod writes require `yes` or `CONFIRM_PROD=1` (orchestrator sets `CONFIRM_PROD=1` when `autoConfirm`-style run)
- No TRUNCATE — harmonize only inserts/updates changed rows
- Rollback: restore from `db/DataBackup/gallery_*_data_*.txt` using `dev:db:restore` or `devtoprod:db:restore`
See also [environments.md](environments.md) and [deploy-dev-to-prod.md](deploy-dev-to-prod.md).
+108
View File
@@ -0,0 +1,108 @@
# Russian localization (i18n)
Full Russian support: **UI chrome** via `react-i18next`, **catalog text** via PostgreSQL `entity_translations`, with Cyrillic display aliases where available and English fallback.
---
## Architecture
| Layer | English | Russian |
|-------|---------|---------|
| UI labels, buttons | `client/src/locales/en/*.json` | `client/src/locales/ru/*.json` |
| Names / titles (display) | canonical DB columns | `entity_translations` (`name`, `title`) |
| Bios, notes, descriptions | canonical DB columns | `entity_translations` (`bio_full`, `body`, `notes`, …) |
English remains canonical in main tables. Russian rows use `status`: `draft``reviewed``published`. Public API returns only **`published`** (unless curator preview).
---
## User-facing locale switch
Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
- Persists `gallery_locale` in `localStorage`
- Sets `document.documentElement.lang`
- Passes `?locale=ru` on catalog API requests
- Refetches bootstrap catalog when locale changes
---
## Setup (dev)
```powershell
npm run dev:migrate # includes migrate-i18n.sql
npm run dev:fetch-artist-bios-ru # draft bios + Cyrillic names from ru.wikipedia
# Curator: Translations page → review → Publish
npm run dev:import-translations -- --file path/to/translations.json --publish
```
Optional manual import JSON:
```json
[
{ "entity_type": "painting", "entity_id": 42, "field_name": "title", "value": "Джоконда", "status": "published", "source": "manual" }
]
```
CSV: `entity_type,entity_id,field_name,value,status,source`
---
## Curator translation review
1. Sign in as curator
2. Header → **Translations** (or **Переводы** in RU UI)
3. Pick entity type (artist / painting / movement)
4. Select row → edit Russian fields side-by-side with English canonical
5. **Publish** saves and marks rows `published`
Coverage stats show artists with `bio_full`, paintings with `title` alias, draft vs published counts.
API (curator-only): see [API.md](API.md#translations-curator).
---
## Translatable fields (v1)
| entity_type | fields |
|-------------|--------|
| `era`, `movement` | `name`, `description` |
| `artist` | `name`, `bio_short`, `bio_full` |
| `artist_period` | `name`, `description` |
| `painting` | `title`, `description` (not `curator_notes` — English-only for now) |
| `annotation` | `label`, `body` |
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
---
## API locale
Public endpoints accept `?locale=ru` or `Accept-Language: ru`. Responses include `"locale": "ru"` on catalog payloads; field names unchanged — values are already resolved.
Search matches canonical text **or** published Russian **name** / **title** aliases (partial index `entity_translations_search_alias_idx`; long `bio_full` text is stored but not btree-indexed).
---
## Prod rollout
1. `npm run dev:migrate` on dev; curator review + publish Russian rows on dev
2. **`npm run devtoprod:release`** (`full` profile) — includes Step 4 schema migrate + Step 5 restore; or `npm run harmonize:schema` then harmonize/sync if you prefer merge over full promote
3. After restore, verify locale toggle on https://gallery.mysuperlab.netcraze.pro
`entity_translations` is included in [`harmonize-db.js`](../scripts/harmonize-db.js) catalog sync.
### `dev:fetch-artist-bios-ru` index limit
Early `migrate-i18n.sql` indexed all `value` text; very long Russian bios (e.g. Michelangelo) could fail with `index row size … exceeds btree maximum`. Current migration uses a **partial** index on `name` and `title` only (`char_length(value) <= 512`). Re-run `npm run dev:migrate` (or prod Step 4) after pulling this fix, then re-run `npm run dev:fetch-artist-bios-ru` for any artists that failed.
---
## npm scripts
| Script | Purpose |
|--------|---------|
| `dev:migrate:i18n` | Apply `entity_translations` table only |
| `dev:fetch-artist-bios-ru` | Fetch ru.wikipedia bios into translations (draft) |
| `dev:import-translations` | Import JSON/CSV translation rows |
See also [setup.md](setup.md), [environments.md](environments.md), [harmonize-dev-prod.md](harmonize-dev-prod.md).
+92
View File
@@ -0,0 +1,92 @@
# Influence links import & curator tool
Curator tool to **list / add / delete** influence edges, **visualize** an artist neighborhood, and **import** CSV / JSON / XLSX files through a mapping wizard.
Entry: header **Influences** (curator session). Data lives in `painting_influence_sources` (legacy `painting_influences` mirrored for painting→painting).
---
## Expansion rule (artist-level files)
Input workbooks such as [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) are **artist-centric**. On import:
- **Influenced by** tokens → attach as sources on **all paintings** of the subject artist
- **Influenced** tokens that resolve to an artist → reverse link: subject artist becomes a source on **all paintings** of the influenced artist (same as PainterPalette `Influencedon`)
Unresolved names (artists / movements / paintings not in the DB) are **skipped** with warnings — no auto-create stubs.
---
## Wizard column roles
| Role | Meaning |
|------|---------|
| `subject_artist` | Artist the row is about (required) |
| `subject_painting` | Optional work hint (contextual; expansion still uses all works) |
| `influenced_by` | Who/what influenced the subject (`;` / `,` separated) |
| `influenced` | Who the subject influenced |
| `notes` / `reference` / `source_url` | Citation metadata (URLs scraped from reference text) |
| `ignore` | Skip column |
### Presets
| Preset | Typical headers | Example file |
|--------|-----------------|--------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) |
| Story of Art | Same Title Case (+ chapter reference column) | [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) |
| Gariff influential painters | `artist`, `painting`, `influenced by`, `influenced`, `reference` | [`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | [`Inputs/art_influences.xlsx`](../Inputs/art_influences.xlsx) |
| Custom | Map any columns manually | — |
Token classification order: **artist → movement → painting title** (under subject artist, then global).
Committed edges use `confidence=curated`, `discovered_via=import-wizard`.
### Book extract: Gariff (2008)
[`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) is distilled from David Gariff et al., *The World's Most Influential Painters and the Artists They Inspired* (Herbert Press / Quarto, 2008; local PDF `Inputs/1.pdf`, not committed).
- Columns match the import wizard (`artist` / `painting` / `influenced by` / `influenced` / `reference`).
- **`artist` and `painting`** are limited to catalog slices in `Inputs/artistslistdbdata-*.csv` and `Inputs/paintinglistdbdata-*.csv` (regenerate those from prod/dev as needed).
- **`influenced by` / `influenced`** may name artists, movements, or works from the book (including entities outside those CSVs).
- Multiple rows per subject when the book states several influences.
- Import via curator **Influences → Import** (Art influences / custom mapping) or keep as a curated source workbook.
See also [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) (Gombrich) and optional local `Inputs/janson_short_history_influences.xlsx` (same column shape).
### Duplicate file / data guard
Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details):
- `contentHash` — raw file bytes
- `payloadHash` — normalized mapped rows (same data under another filename still matches)
On parse/preview, if either hash matches a prior import, the UI warns and **blocks commit** unless the curator checks **Import anyway (force)**. Individual edges remain unique via DB `ON CONFLICT` either way.
---
## API (curator)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/influences` | List (`artistId`, `paintingId`, `q`, `limit`, `offset`) |
| `GET` | `/api/influences/graph?artistId=` | Nodes + edges for SVG neighborhood |
| `POST` | `/api/influences` | Create one edge |
| `PATCH` | `/api/influences/:id` | Update metadata / remap source |
| `DELETE` | `/api/influences/:id` | Delete (+ legacy mirror) |
| `POST` | `/api/influences/import/parse` | `{ filename, contentBase64, sheet? }` → columns, hashes, `alreadyImported` |
| `POST` | `/api/influences/import/preview` | `{ rows, mapping, contentHash?, payloadHash? }` → proposals + duplicate check |
| `POST` | `/api/influences/import/commit` | `{ proposals, fileName?, contentHash?, payloadHash?, force? }``409 ALREADY_IMPORTED` unless `force` |
Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log`.
---
## CLI still available
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
- `npm run dev:import-painter-palette` — PainterPalette CSV
The wizard is the interactive path for ad-hoc spreadsheets under `Inputs/`.
See also [API.md](API.md), [DB_structure.md](DB_structure.md), [Plans.md](Plans.md).
+25 -8
View File
@@ -29,9 +29,9 @@ cp .env.example .env
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
| `SESSION_SECRET` | Random string for signed session cookies (required for curator login) |
| `SESSION_COOKIE_SECURE` | `false` for local HTTP dev; `true` in prod behind HTTPS |
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty |
| `SESSION_COOKIE_SECURE` | Optional — omit for auto (HTTPS via proxy → Secure); set `true`/`false` to force |
| `CURATOR_USERNAME` | Bootstrap / reset — curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap when `users` is empty; also used by `npm run dev:reset-curator` |
`.env` is git-ignored; never commit passwords.
@@ -70,9 +70,9 @@ If migration fails with permission errors, grant schema rights to the app user f
### Curator accounts (auth migration)
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically.
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
@@ -88,6 +88,10 @@ npm run dev:expand-catalog # famous works for artists below MIN_PAIN
npm run dev:update-influences # painting influence graph for detail view + hall exits
npm run dev:migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
npm run dev:migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
npm run dev:migrate:search # optional on very old DBs — also applied by dev:migrate / prod Step 4
npm run dev:migrate:i18n # optional — entity_translations (also in dev:migrate)
npm run dev:fetch-artist-bios-ru # draft Russian bios + Cyrillic names from ru.wikipedia
npm run dev:import-translations -- --file path/to/file.json # bulk translation import
npm run dev:migrate:painting-annotations # optional: art-history notes table
npm run dev:update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
npm run dev:fetch-images -- --limit=50 # random sample; 10s max per painting (default)
@@ -109,13 +113,14 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| `npm run prodto:dev:images` | Copy prod images → dev repo |
| `npm run prodto:dev:db` | Clone `gallery_prod``gallery_dev` |
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote DB to prod |
| `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run prod:build` | Build production SPA into `client/dist` |
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
| `npm run prod:start` | Build client, then start server |
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
| `npm run dev:client` | Vite dev server on :5173 |
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. Quick reference: [FAC.md](FAC.md).
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md).
**Production frontend:** build the client, then start the server:
@@ -177,6 +182,7 @@ Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueN
| `npm run dev:migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
| `npm run dev:migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
| `npm run dev:migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
| `npm run dev:migrate:search` | `scripts/migrate-search.js` | Same indexes as `migrate-search.sql` (also run by `dev:migrate`) |
| `npm run dev:migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
| `npm run dev:migrate:artist-palette` | `scripts/migrate-artist-palette.js` | Add `palette_metadata` JSONB on `artists` |
| `npm run dev:import-painter-palette` | `scripts/import-painter-palette.js` | Enrich artists + influence links from `Inputs/PainterPalette.csv` |
@@ -258,18 +264,29 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run dev:start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` |
| Debug **Remove entry** — button stuck or missing on next painting | Stale client build | `cd client && npm run build`; hard-refresh — detail view remounts per painting id |
| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload |
| Debug **Upload** — nothing happens after choosing file | Stale client or broken hidden-file `click()` | Pull latest client (`DebugUploadButton` uses `<label>` + `<input>`); hard-refresh |
| Uploaded image reverts after reload (old picture) | Browser cached `/images/…` at same path | Pull latest server + client — API returns `image_cache_key` and URLs use `?v=`; `/images` no longer uses immutable long-term cache |
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run dev:migrate:painting-annotations` then `npm run dev:update-painting-annotations` |
| **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides |
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) |
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix/upload updates session + `?v=` from `image_cache_key` — use **Back to Gallery** (not browser back) |
| **Back to Timeline** returns to gallery / previous wing | Stale client build | Pull latest client — `goToTimelineHome()` unmounts the hall and resets timeline zoom |
| Catalog search dropdown hidden under timeline | Stale client CSS | Rebuild client — `.site-header` uses `z-index: 110` above the sticky timeline bar |
| Catalog search returns empty / 500 | Search indexes missing | Run `npm run dev:migrate` (includes `migrate-search.sql`) or `npm run dev:migrate:search`; restart API |
| `fetch-artist-bios-ru` fails: `index row size … exceeds btree maximum` | Old `entity_translations` index on all `value` text | `npm run dev:migrate` (partial search index on name/title only), then re-run `npm run dev:fetch-artist-bios-ru` |
| Russian UI shows English catalog names | Translations not published | Curator → Translations → Publish; public API serves only `status = published` |
| Influence import leaves many unresolved tokens | Names not in catalog DB | Curator → Influences → Import warnings; fix spelling or add artists first; free-text traditions stay unresolved |
| Influence import created too many edges | Artist-level rows expand to all paintings | Expected (PainterPalette-style); delete unwanted edges in Influences list |
| Influence import blocked: already imported | Same file bytes or mapped data imported before | Expected; use **Import anyway** only if intentional, or skip |
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually |
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
| Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate`, restart server |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate` (or `npm run dev:reset-curator`), restart server |
| Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) |
| Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes |
| **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` |
| **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready |
| 3D hall loading overlay never clears | Stuck texture counter or hung HDR | Hard-refresh; current client times out Environment / shader warm-up so the overlay cannot stay forever |
| 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again |
+47
View File
@@ -0,0 +1,47 @@
# Guided tours
Curated walkthroughs of selected paintings. Visitors open **Tours** on the timeline, pick a published tour, and walk a 3D hall (`VirtualGallery` mode `tour`). Opening a frame shows stop notes on painting detail; prev/next follow tour order.
## Status
| Status | Who sees it |
|--------|-------------|
| `draft` | Curators only (Tour editor + `GET /api/tours/:id` when signed in) |
| `published` | Everyone via Tours popup + public API |
Tour stop **body** text is English-only in v1 (stored on `tour_stops.body`). UI chrome is EN/RU via i18n.
## Data model
Migration: `db/migrate-tours.sql` (applied by `npm run dev:migrate`).
| Table | Role |
|-------|------|
| `tours` | Title, description, `status`, optional `cover_painting_id` |
| `tour_stops` | Ordered stops: `tour_id`, `painting_id`, `sort_order`, `body` (unique per tour+painting) |
Included in `scripts/harmonize-db.js` catalog sync.
## Curator editor
Home header → **Tour editor** (curators). Create tours, set draft/published, search-add paintings, reorder, edit stop text, save.
## Visitor flow
1. Timeline → **Tours** → modal of published tours
2. Select tour → `GET /api/tours/:id` → winged 3D hall (left wall first → right wall last, stop order preserved)
3. Click frame → painting detail with tour notes + stop-ordered navigation
4. Back / hall exit → timeline
Hall hang rules match artist and movement galleries — see [basics.md — Virtual gallery](basics.md#virtual-gallery-3d-halls).
## API
See [API.md](API.md#tours). Audit actions: `tour.create`, `tour.update`, `tour.delete`, `tour.stops`.
## Out of scope (v1)
- Russian tour body translations
- Auto-generated / AI tours
- Audio or timed slideshow
- Editing tours from painting detail
+125
View File
@@ -0,0 +1,125 @@
Here are 100 standout books by art historians and specialists covering the artists in your lists and their movements, ordered roughly by period. (Your list includes Apelles, Zeuxis, Parrhasius, the icon painters, and everyone through Pop Art, so I've covered all those areas; per your earlier instruction I've left out Eastern and African art scholarship.)
**Classical antiquity & icons (Apelles, Zeuxis, Parrhasius; Rublev, Theophanes)**
1. J. J. Pollitt — *Art and Experience in Classical Greece* (1972)
2. J. J. Pollitt — *The Art of Ancient Greece: Sources and Documents* (1990)
3. Ernst Gombrich — *Art and Illusion* (1960; central discussion of Greek mimesis and the Zeuxis/Parrhasius tradition)
4. Viktor Lazarev — *The Russian Icon: From Its Origins to the Sixteenth Century* (English ed. 1997)
5. Robin Cormack — *Icons* (2007)
**Trecento & Early Renaissance (Cimabue, Giotto, Duccio, Simone Martini, Masaccio, Fra Angelico, Castagno, Ghirlandaio, Botticelli)**
6. Giorgio Vasari — *Lives of the Artists* (1550/1568; the founding text)
7. Bernard Berenson — *The Italian Painters of the Renaissance* (1930 collected ed.)
8. John White — *Art and Architecture in Italy 12501400* (1966)
9. Millard Meiss — *Painting in Florence and Siena after the Black Death* (1951)
10. Bruce Cole — *Giotto and Florentine Painting 12801375* (1976)
11. Francesca Flores d'Arcais — *Giotto* (1995)
12. John White — *Duccio: Tuscan Art and the Medieval Workshop* (1979)
13. Andrew Martindale — *Simone Martini* (1988)
14. Michael Baxandall — *Painting and Experience in Fifteenth-Century Italy* (1972)
15. Paul Joannides — *Masaccio and Masolino: A Complete Catalogue* (1993)
16. John Pope-Hennessy — *Fra Angelico* (1952, rev. 1974)
17. Diane Cole Ahl — *Fra Angelico* (2008)
18. Herbert Horne — *Alessandro Filipepi, Called Sandro Botticelli* (1908; still the classic monograph)
19. Ronald Lightbown — *Sandro Botticelli: Life and Work* (1978)
20. Jean Cadogan — *Domenico Ghirlandaio: Artist and Artisan* (2000)
**High Renaissance (Leonardo, Michelangelo, Raphael, Giorgione, Titian)**
21. Heinrich Wölfflin — *Classic Art* (1899)
22. Sydney Freedberg — *Painting of the High Renaissance in Rome and Florence* (1961)
23. Kenneth Clark — *Leonardo da Vinci* (1939)
24. Martin Kemp — *Leonardo da Vinci: The Marvellous Works of Nature and Man* (1981)
25. Charles de Tolnay — *Michelangelo* (5 vols., 19431960)
26. Howard Hibbard — *Michelangelo* (1974)
27. Roger Jones & Nicholas Penny — *Raphael* (1983)
28. John Pope-Hennessy — *Raphael* (1970)
29. Salvatore Settis — *Giorgione's Tempest: Interpreting the Hidden Subject* (English ed. 1990)
30. Johannes Wilde — *Venetian Art from Bellini to Titian* (1974)
31. Harold Wethey — *The Paintings of Titian* (3 vols., 19691975)
32. Charles Hope — *Titian* (1980)
33. David Rosand — *Painting in Cinquecento Venice* (1982)
**Northern Renaissance (van Eyck, Bosch, Dürer)**
34. Erwin Panofsky — *Early Netherlandish Painting* (1953)
35. Otto Pächt — *Van Eyck and the Founders of Early Netherlandish Painting* (English ed. 1994)
36. Craig Harbison — *Jan van Eyck: The Play of Realism* (1991)
37. Charles de Tolnay — *Hieronymus Bosch* (English ed. 1966)
38. Walter Gibson — *Hieronymus Bosch* (1973)
39. Erwin Panofsky — *The Life and Art of Albrecht Dürer* (1943)
40. Joseph Koerner — *The Moment of Self-Portraiture in German Renaissance Art* (1993)
**Mannerism (Pontormo, Bronzino, Parmigianino, El Greco)**
41. John Shearman — *Mannerism* (1967)
42. Sydney Freedberg — *Painting in Italy 15001600* (1971)
43. Cecil Gould — *Parmigianino* (1994)
44. Maurice Brock — *Bronzino* (2002)
45. Elizabeth Cropper — *Pontormo: Portrait of a Halberdier* (1997)
46. Harold Wethey — *El Greco and His School* (1962)
47. Fernando Marías — *El Greco: Life and Work — A New History* (2013)
**Baroque (Caravaggio, Gentileschi, Rubens, Rembrandt, Velázquez, Poussin, Claude)**
48. Rudolf Wittkower — *Art and Architecture in Italy 16001750* (1958)
49. Walter Friedlaender — *Caravaggio Studies* (1955)
50. Howard Hibbard — *Caravaggio* (1983)
51. Helen Langdon — *Caravaggio: A Life* (1998)
52. Mary Garrard — *Artemisia Gentileschi: The Image of the Female Hero in Italian Baroque Art* (1989)
53. Michael Jaffé — *Rubens and Italy* (1977)
54. Kristin Lohse Belkin — *Rubens* (Phaidon, 1998)
55. Jakob Rosenberg — *Rembrandt: Life and Work* (1948)
56. Svetlana Alpers — *Rembrandt's Enterprise: The Studio and the Market* (1988)
57. Ernst van de Wetering — *Rembrandt: The Painter at Work* (1997)
58. Simon Schama — *Rembrandt's Eyes* (1999)
59. Jonathan Brown — *Velázquez: Painter and Courtier* (1986)
60. Anthony Blunt — *Nicolas Poussin* (1967)
61. Marcel Röthlisberger — *Claude Lorrain: The Paintings* (1961)
**Eighteenth century (Watteau, Boucher, Fragonard, Kauffman)**
62. Donald Posner — *Antoine Watteau* (1984)
63. Michael Levey — *Rococo to Revolution* (1966)
64. Alastair Laing et al. — *François Boucher 17031770* (Met exhibition catalogue, 1986)
65. Pierre Rosenberg — *Fragonard* (Met exhibition catalogue, 1988)
66. Angela Rosenthal — *Angelica Kauffman: Art and Sensibility* (2006)
**Neoclassicism & Romanticism (David, Ingres, Goya, Géricault, Delacroix, Turner, Constable, Friedrich)**
67. Robert Rosenblum — *Transformations in Late Eighteenth Century Art* (1967)
68. Anita Brookner — *Jacques-Louis David* (1980)
69. Robert Rosenblum — *Jean-Auguste-Dominique Ingres* (1967)
70. Fred Licht — *Goya: The Origins of the Modern Temper in Art* (1979)
71. Janis Tomlinson — *Goya: A Portrait of the Artist* (2020)
72. Lorenz Eitner — *Géricault: His Life and Work* (1983)
73. Barthélémy Jobert — *Delacroix* (1998)
74. Andrew Wilton — *J. M. W. Turner: His Art and Life* (1979)
75. John Gage — *Colour in Turner: Poetry and Truth* (1969)
76. Michael Rosenthal — *Constable: The Painter and His Landscape* (1983)
77. Joseph Koerner — *Caspar David Friedrich and the Subject of Landscape* (1990)
**Realism (Courbet, Millet, Daumier, Corot)**
78. Linda Nochlin — *Realism* (1971)
79. T. J. Clark — *Image of the People: Gustave Courbet and the 1848 Revolution* (1973)
80. T. J. Clark — *The Absolute Bourgeois: Artists and Politics in France 18481851* (1973; Millet and Daumier)
81. Peter Galassi — *Corot in Italy* (1991)
**Impressionism (Manet, Monet, Renoir, Degas, Pissarro)**
82. John Rewald — *The History of Impressionism* (1946; still the standard narrative)
83. Robert Herbert — *Impressionism: Art, Leisure, and Parisian Society* (1988)
84. T. J. Clark — *The Painting of Modern Life: Paris in the Art of Manet and His Followers* (1985)
85. Michael Fried — *Manet's Modernism* (1996)
86. Paul Hayes Tucker — *Claude Monet: Life and Art* (1995)
87. Barbara Ehrlich White — *Renoir: His Life, Art, and Letters* (1984)
88. Jean Sutherland Boggs et al. — *Degas* (1988 exhibition catalogue; the standard reference)
89. Richard Brettell — *Pissarro and Pontoise* (1990)
**Post-Impressionism & Symbolism (Cézanne, van Gogh, Gauguin, Seurat, Toulouse-Lautrec, Munch, Redon, Puvis, Böcklin, Klimt, Beardsley, Mucha)**
90. John Rewald — *Post-Impressionism: From Van Gogh to Gauguin* (1956)
91. Meyer Schapiro — *Paul Cézanne* (1952)
92. Roger Fry — *Cézanne: A Study of His Development* (1927)
93. Jan Hulsker — *The New Complete Van Gogh* (1996)
94. Debora Silverman — *Van Gogh and Gauguin: The Search for Sacred Art* (2000)
95. Richard Brettell et al. — *The Art of Paul Gauguin* (1988 exhibition catalogue)
96. Robert Herbert — *Seurat and the Making of "La Grande Jatte"* (2004)
97. Julia Frey — *Toulouse-Lautrec: A Life* (1994)
98. Reinhold Heller — *Munch: His Life and Work* (1984)
99. Douglas Druick et al. — *Odilon Redon: Prince of Dreams* (1994)
100. Carl Schorske — *Fin-de-Siècle Vienna: Politics and Culture* (1980; the classic frame for Klimt)
I stopped at a strict 100, which meant the twentieth-century avant-gardes got squeezed out. If you'd like, I can give you a second list of ~40 covering the rest of your artists — the essentials there would be things like Alfred Barr's *Matisse*, John Richardson's *A Life of Picasso*, John Golding's *Cubism*, Camilla Gray's *The Russian Experiment in Art* (Malevich, Tatlin, Rodchenko, Popova), Werner Spies on Ernst, Dawn Ades on Dalí, Jacques Dupin on Miró, Irving Sandler's *The Triumph of American Painting* (Pollock, Rothko, de Kooning), and Lucy Lippard's *Pop Art* (Warhol, Lichtenstein, Hamilton). Just say the word.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+415
View File
@@ -0,0 +1,415 @@
artist,painting,link,size
Albrecht Dürer,Abduction of a Woman,https://www.wga.hu/art/d/durer/2/11/1/10abduct.jpg,283 x 423 mm
Albrecht Dürer,Adam,https://www.wga.hu/art/d/durer/1/06/2adam.jpg,209 x 81 cm
Albrecht Dürer,Apostles Philip and James the Elder,https://www.wga.hu/art/d/durer/1/08/6apostl.jpg,46 x 37 cm
Albrecht Dürer,Christ as the Man of Sorrows,https://www.wga.hu/art/d/durer/1/01/05sorrow.jpg,30 x 19 cm
Albrecht Dürer,Courtyard of Innsbruck Castle With Clouds,https://www.wga.hu/art/d/durer/2/16/1/04courty.jpg,335 x 267 mm
Albrecht Dürer,Courtyard of Innsbruck Castle Without Clouds,https://www.wga.hu/art/d/durer/2/16/1/03courty.jpg,335 x 267 mm
Albrecht Dürer,Eve,https://www.wga.hu/art/d/durer/1/06/3eve.jpg,209 x 81 cm
Albrecht Dürer,Fiesole Altarpiece,https://www.wga.hu/art/d/durer/1/06/7heller.jpg,190 x 260 cm
Albrecht Dürer,Portrait of Barbara Holper,https://www.wga.hu/art/d/durer/1/01/01mother.jpg,47 x 38 cm
Albrecht Dürer,Portrait of an unknown praying man,https://www.wga.hu/art/d/durer/2/11/5/11portra.jpg,365 x 258 mm
Albrecht Dürer,Salvator Mundi with the Child Jesus,https://www.wga.hu/art/d/durer/1/05/02salvat.jpg,57 x 46 cm
Albrecht Dürer,The Circumcision of Christ,https://www.wga.hu/art/d/durer/2/12/5virgin/10_life.jpg,
Albrecht Dürer,The Death of Orpheus,https://www.wga.hu/art/d/durer/2/11/1/05orpheu.jpg,289 x 225 mm
Albrecht Dürer,The Passion of Christ,https://www.wga.hu/art/d/durer/2/12/7smallp/2/14_small.jpg,
Albrecht Dürer,Three Musicians,https://www.wga.hu/art/d/durer/1/04/1jabach2.jpg,94 x 51 cm
Albrecht Dürer,Wire drawing mill near Nyremberg,https://www.wga.hu/art/d/durer/2/16/1/02mill.jpg,286 x 426 mm
Alphonse Mucha,Gismonda,https://www.wga.hu/art/m/mucha/poster1.jpg,213 x 75 cm
Andrea del Castagno,Last Supper,https://www.wga.hu/art/a/andrea/castagno/1_1440s/08lasts1.jpg,453 x 975 cm
Andrei Rublev,Archangel Michael,https://www.wga.hu/art/r/rublyov/deesis2.jpg,158 x 106 cm
Andrei Rublev,Holy Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
Andrei Rublev,Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
Andrei Rublev,Icon with the Eastern Orthodox Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
Andrei Rublev,Icon with the Old Testament Trinity The Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
Andrei Rublev,Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
Angelica Kauffman,Self-Portrait of the Artist Hesitating Between the Arts of Music and Painting,https://www.wga.hu/art/k/kauffman/self_por.jpg,128 x 94 cm
Angelica Kauffman,Venus Induces Helen to Fall in Love with Paris,https://www.wga.hu/art/k/kauffman/venushel.jpg,102 x 128 cm
Arnold Böcklin,Isle of the Dead,https://www.wga.hu/art/b/bocklin/isle1.jpg,111 x 155 cm
Artemisia Gentileschi,Judith Slaying Holofernes,https://www.wga.hu/art/g/gentiles/artemisi/judit.jpg,159 x 126 cm
Artemisia Gentileschi,Judith and Her Maidservant,https://www.wga.hu/art/g/gentiles/artemisi/judith_m.jpg,114 x 93.5 cm
Artemisia Gentileschi,Self-Portrait as the Allegory of Painting,https://www.wga.hu/art/g/gentiles/artemisi/selfport.jpg,"96,5 x 73,7 cm"
Aubrey Beardsley,The Dancer s Reward,https://www.wga.hu/art/b/beardsle/beards04.jpg,342 x 272 mm
Aubrey Beardsley,The Yellow Book,https://www.wga.hu/art/b/beardsle/beards07.jpg,
Bronzino,Holy Family with St Anne and the Infant St John,https://www.wga.hu/art/b/bronzino/3/holyfam.jpg,127 x 102 cm
Bronzino,Miracle of the Spring,https://www.wga.hu/art/b/bronzino/3a/05eleono.jpg,
Bronzino,Portrait of Cosimo I de Medici in Armour,https://www.wga.hu/art/b/bronzino/1/cosimo1a.jpg,118 x 99 cm
Bronzino,Portrait of Eleonora di Toledo with her son Giovanni,https://www.wga.hu/art/b/bronzino/1/eleonorb.jpg,59 x 46 cm
Bronzino,Venus Cupid Folly and Time,https://www.wga.hu/art/b/bronzino/4/venus_cu.jpg,147 x 117 cm
Camille Pissarro,Boulevard Montmartre,https://www.wga.hu/art/p/pissarro/camille/2/paris03.jpg,53 x 65 cm
Camille Pissarro,Jalais Hill Pontoise,https://www.wga.hu/art/p/pissarro/camille/1/ponto01.jpg,89 x 116 cm
Camille Pissarro,Lordship Lane Station,https://www.wga.hu/art/p/pissarro/camille/4/variou2.jpg,45 x 73 cm
Camille Pissarro,Red Roofs,https://www.wga.hu/art/p/pissarro/camille/3/winter15.jpg,54 x 65 cm
Caravaggio,Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
Caravaggio,David with the Head of Goliath,https://www.wga.hu/art/c/caravagg/08/52david.jpg,91 x 116 cm
Caravaggio,Madonna di Crevole,https://www.wga.hu/art/c/caravagg/07/42loreto.jpg,260 x 150 cm
Caravaggio,Mary Magdalene,https://www.wga.hu/art/c/caravagg/02/12magda.jpg,123 x 99 cm
Caravaggio,Saint John the Baptist,https://www.wga.hu/art/c/caravagg/10/62behead.jpg,361 x 520 cm
Caravaggio,Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
Caravaggio,Saint Thomas,https://www.wga.hu/art/c/caravagg/06/34thomas.jpg,107 x 146 cm
Caravaggio,The Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
Caravaggio,The Crucifixion of Saint Peter,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
Caravaggio,The Crucifixion of Saint Peter and the Decapitation of Saint John the Baptist,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
Caspar David Friedrich,Wanderer above the Sea of Fog,https://www.wga.hu/art/f/friedric/2/209fried.jpg,95 x 75 cm
Cimabue,Madonna Enthroned with the Child St Francis and Four Angels,https://www.wga.hu/art/c/cimabue/madonna/madonn_1.jpg,320 x 340 cm
Cimabue,Madonna and Child Enthroned with Angels and Prophets Santa Trinit Maest,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
Cimabue,Santa Trinita Maestà,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
Claude Lorrain,Embarkation of the Queen of Sheba,https://www.wga.hu/art/c/claude/2/10sheba.jpg,148 x 194 cm
Claude Monet,"Impression, Sunrise",https://www.wga.hu/art/m/monet/03/0impress.jpg,48 x 63 cm
Claude Monet,Rouen Cathedral,https://www.wga.hu/art/m/monet/07/5cathed3.jpg,100 x 65 cm
Claude Monet,The Japanese Bridge,https://www.wga.hu/art/m/monet/08/3bridge5.jpg,89 x 116 cm
Claude Monet,The Lunch,https://www.wga.hu/art/m/monet/02/1vario02.jpg,230 x 150 cm
Claude Monet,Water Lilies,https://www.wga.hu/art/m/monet/08/7liliy01.jpg,90 x 100 cm
Diego Velázquez,Christ in the House of Martha and Mary,https://www.wga.hu/art/v/velazque/01/0108vela.jpg,"60 x 103,5 cm"
Diego Velázquez,Kitchen Scene,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
Diego Velázquez,Kitchen Scene with the Supper in Emmaus,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
Diego Velázquez,Las Meninas,https://www.wga.hu/art/v/velazque/08/0801vela.jpg,318 x 276 cm
Diego Velázquez,Philip IV of Spain 1605 1665,https://www.wga.hu/art/v/velazque/02/0204vela.jpg,210 x 102 cm
Diego Velázquez,Portrait du Conde-Duque de Olivares,https://www.wga.hu/art/v/velazque/05/0508vela.jpg,314 x 240 cm
Diego Velázquez,Portrait of Philip IV of Spain,https://www.wga.hu/art/v/velazque/10/1002vela.jpg,"47 x 37,5 cm"
Diego Velázquez,Queen Mariana of Spain 1634 1696,https://www.wga.hu/art/v/velazque/10/1003vela.jpg,231 x 131 cm
Diego Velázquez,The Buffoon Pablo de Valladolid,https://www.wga.hu/art/v/velazque/06/0604vela.jpg,214 x 125 cm
Diego Velázquez,The Dwarf Francisco Lezcano,https://www.wga.hu/art/v/velazque/07/0702vela.jpg,107 x 83 cm
Domenico Ghirlandaio,Birth of the Virgin,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/2birth.jpg,
Domenico Ghirlandaio,Confirmation of the Rule,https://www.wga.hu/art/g/ghirland/domenico/5sassett/frescoes/5confir.jpg,
Domenico Ghirlandaio,Expulsion of Joachim from the Temple,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/1expuls.jpg,
Domenico Ghirlandaio,Madonna of Mercy and Lamentation,https://www.wga.hu/art/g/ghirland/domenico/1early/2ogniss.jpg,
Domenico Ghirlandaio,Marriage of Mary,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/4marria.jpg,
Domenico Ghirlandaio,Portrait of Giovanna Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
Domenico Ghirlandaio,Portrait of Lucrezia Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
Domenico Ghirlandaio,Saint John the Evangelist on the Island of Patmos,https://www.wga.hu/art/g/ghirland/domenico/7panel/02patmos.jpg,
Duccio,Christ Accused by the Pharisees,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso12.jpg,49 x 57 cm
Duccio,Christ and the Samaritan Woman,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_6.jpg,44 x 46 cm
Duccio,Disputation with the Doctors,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_7.jpg,43 x 43 cm
Duccio,Entry into Jerusalem,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso01.jpg,100 x 57 cm
Duccio,Gualino Madonna,https://www.wga.hu/art/d/duccio/various/22gualin.jpg,157 x 86 cm
Duccio,Jesus Appears on Lake Tiberias,https://www.wga.hu/art/d/duccio/maesta/crown_v/cro_v_3.jpg,37 x 48 cm
Duccio,Madonna with Child and six Angels,https://www.wga.hu/art/d/duccio/various/6perugi.jpg,97 x 63 cm
Duccio,Maest - Backside,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
Duccio,Maestà,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
Duccio,Pact of Judas top Christ Taking Leave of the Apostles bottom,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso04.jpg,50 x 53 cm
Duccio,Rucellai Madonna,https://www.wga.hu/art/d/duccio/ruccelai/3ruccela.jpg,450 x 290 cm
Duccio,The Calling of the Apostles Peter and Andrew,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_4.jpg,44 x 46 cm
Duccio,The Healing of the Man Born Blind,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_7.jpg,45 x 47 cm
Duccio,The Madonna of the Franciscans,https://www.wga.hu/art/d/duccio/various/51franci.jpg,"23,5 x 16 cm"
Duccio,The Nativity with the Prophets Isaiah and Ezekiel,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_1d.jpg,48 x 87 cm
Duccio,The Temptation of Christ on the Mountain,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_3.jpg,43 x 46 cm
Duccio,The Wedding at Cana,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_5.jpg,44 x 47 cm
Duccio,The three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
Duccio,Three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
Duccio,Three Marys at the Tomb top Descent into Hell bottom,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
Edgar Degas,Blue Dancer,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
Edgar Degas,Blue Dancers,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
Edgar Degas,The Ballet Class,https://www.wga.hu/art/d/degas/4/1880s_03.jpg,82 x 77 cm
Edvard Munch,Anxiety,https://www.wga.hu/art/m/munch/scream5.jpg,94 x 74 cm
Edvard Munch,Madonna,https://www.wga.hu/art/m/munch/madonna1.jpg,91 x 71 cm
Edvard Munch,The Dance of Life,https://www.wga.hu/art/m/munch/stages2.jpg,125 x 191 cm
Edvard Munch,The Scream,https://www.wga.hu/art/m/munch/scream1.jpg,91 x 74 cm
Edvard Munch,The Sick Child,https://www.wga.hu/art/m/munch/sick1.jpg,120 x 119 cm
El Greco,Disrobement of Christ,https://www.wga.hu/art/g/greco_el/04/0401grec.jpg,
El Greco,Dormition of the Virgin,https://www.wga.hu/art/g/greco_el/01/0101grec.jpg,"61,4 x 45 cm"
El Greco,Ecstasy of St Francis,https://www.wga.hu/art/g/greco_el/06/0610grec.jpg,102 x 75 cm
El Greco,Miracle of Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
El Greco,Saint Francis reveives the stigmata,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
El Greco,Saint Joseph,https://www.wga.hu/art/g/greco_el/06/0613grec.jpg,68 x 56 cm
El Greco,Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
El Greco,Saint Peter Weeping,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
El Greco,Studies for Martyrdom of Saint Peter the Martyr,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
El Greco,The Penitent Saint Jerome,https://www.wga.hu/art/g/greco_el/21/2104grec.jpg,166 x 110 cm
El Greco,The Stigmata of Saint Francis,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
El Greco,View of Toledo,https://www.wga.hu/art/g/greco_el/11/1104grec.jpg,121 x 109 cm
El Greco,the repose of the Virgin Mary,https://www.wga.hu/art/g/greco_el/10/1010grec.jpg,52 x 36 cm
Eugène Delacroix,Arab Horses Fighting in a Stable,https://www.wga.hu/art/d/delacroi/5/512delac.jpg,"64,6 x 81 cm"
Eugène Delacroix,Liberty Leading the People,https://www.wga.hu/art/d/delacroi/2/208delac.jpg,260 x 325 cm
Eugène Delacroix,The Death of Sardanapalus,https://www.wga.hu/art/d/delacroi/2/204delac.jpg,392 x 496 cm
Eugène Delacroix,The Massacre at Chios,https://www.wga.hu/art/d/delacroi/1/107delac.jpg,419 x 354 cm
Eugène Delacroix,Women of Algiers in their Apartment,https://www.wga.hu/art/d/delacroi/3/307delac.jpg,180 x 229 cm
Fra Angelico,Apparition at Arles,https://www.wga.hu/art/a/angelico/12/02compa4.jpg,26 x 31 cm
Fra Angelico,Birth of the Virgin,https://www.wga.hu/art/a/angelico/04/2edge1.jpg,23 x 14 cm
Fra Angelico,Compagnia di San Francesco altarpiece,https://www.wga.hu/art/a/angelico/12/01compag.jpg,189 x 81 cm
Fra Angelico,Deposition from the Cross,https://www.wga.hu/art/a/angelico/08/1trinita.jpg,176 x 185 cm
Fra Angelico,Saint Francis and a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
Fra Angelico,Sant Ambrogio Altarpiece,https://www.wga.hu/art/a/angelico/07/altar_sm.jpg,220 x 227 cm
Fra Angelico,The Annunciation,https://www.wga.hu/art/a/angelico/09/cells/03_annun.jpg,176 x 148 cm
Fra Angelico,The Funeral of a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
Fra Angelico,The Meeting of Saint Dominic and Saint Francis of Assisi,https://www.wga.hu/art/a/angelico/13/03prede4.jpg,27 x 26 cm
Francisco Goya,Dream of St Gregory,https://www.wga.hu/art/g/goya/4/403goya.jpg,188 x 113 cm
Francisco Goya,Portrait of Andr Derain,https://www.wga.hu/art/g/goya/4/407goya.jpg,"95 x 65,7 cm"
Francisco Goya,Portrait of the Duke of Wellington,https://www.wga.hu/art/g/goya/7/707goya.jpg,64 x 52 cm
Francisco Goya,The Clothed Maja,https://www.wga.hu/art/g/goya/5/502goya.jpg,97 x 190 cm
Francisco Goya,The Naked Maja,https://www.wga.hu/art/g/goya/5/501goya.jpg,97 x 190 cm
Francisco Goya,The Third of May 1808,https://www.wga.hu/art/g/goya/7/714goya.jpg,266 x 345 cm
Francisco Goya,Witches Sabbath,https://www.wga.hu/art/g/goya/2/218goya.jpg,43 x 30 cm
François Boucher,Madame de Pompadour,https://www.wga.hu/art/b/boucher/2/pompado2.jpg,"72,5 x 57 cm"
François Boucher,The Blonde Odalisque,https://www.wga.hu/art/b/boucher/2/o_murph.jpg,59 x 73 cm
François Boucher,The Toilet of Venus,https://www.wga.hu/art/b/boucher/2/venus_to.jpg,108 x 85 cm
Georges Seurat,Bathers at Asni res,https://www.wga.hu/art/s/seurat/figures/bathers1.jpg,201 x 300 cm
Georges Seurat,Models,https://www.wga.hu/art/s/seurat/figures/models.jpg,39 x 49 cm
Georges Seurat,Sunday Afternoon on the Island of La Grande Jatte,https://www.wga.hu/art/s/seurat/figures/jatte.jpg,208 x 308 cm
Georges Seurat,The Circus,https://www.wga.hu/art/s/seurat/figures/zcircus1.jpg,180 x 148 cm
Georges Seurat,The Lighthouse at Honfleur,https://www.wga.hu/art/s/seurat/landscap/honfleu3.jpg,67 x 82 cm
Georges Seurat,Young Woman Powdering Herself,https://www.wga.hu/art/s/seurat/figures/powderin.jpg,96 x 80 cm
Giorgione,A Woman Spinning and an Old Woman,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
Giorgione,Old Woman Cooking Eggs,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
Giorgione,Sleeping Venus,https://www.wga.hu/art/g/giorgion/various/venus.jpg,108 x 175 cm
Giorgione,The Pastoral Concert,https://www.wga.hu/art/g/giorgion/various/concert.jpg,110 x 138 cm
Giotto,Apparition to Fra Agostino and to Bishop Guido of Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc21.jpg,270 x 230 cm
Giotto,Badia Polyptych,https://www.wga.hu/art/g/giotto/z_panel/2panel/5badia.jpg,91x334 cm
Giotto,Canonization of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc24.jpg,270 x 230 cm
Giotto,Carnesecchi Triptych,https://www.wga.hu/art/g/giotto/z_panel/4stefane/10stefan.jpg,220 x 245 cm
Giotto,Death and Ascension of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc20.jpg,270 x 230 cm
Giotto,Death of the Knight of Celano,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc16.jpg,270 x 230 cm
Giotto,Descent into Hell,https://www.wga.hu/art/g/giotto/z_panel/3polypty/6limbo.jpg,45 x 44 cm
Giotto,Dream of Innocent III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc06.jpg,270 x 230 cm
Giotto,Dream of the Palace,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc03.jpg,270 x 230 cm
Giotto,Exorcism of the Demons at Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc10.jpg,270 x 230 cm
Giotto,Homage of a Simple Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc01.jpg,270 x 230 cm
Giotto,Institution of the Crib at Greccio,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc13.jpg,270 x 230 cm
Giotto,Joachim s Dream,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi5.jpg,200 x 185 cm
Giotto,Joachim s Sacrificial Offering,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi4.jpg,200 x 185 cm
Giotto,Miracle of the Crucifix,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc04.jpg,270 x 230 cm
Giotto,Renunciation of Wordly Goods,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc05.jpg,270 x 230 cm
Giotto,Saint Francis,https://www.wga.hu/art/g/giotto/assisi/upper/05saints.jpg,
Giotto,Scenes from the New Testament Lamentation,https://www.wga.hu/art/g/giotto/assisi/upper/new_test/05lamen.jpg,300 x 300 cm
Giotto,Sermon to the Birds,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc15.jpg,270 x 200 cm
Giotto,St Francis Giving his Mantle to a Poor Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc02.jpg,270 x 230 cm
Giotto,St Francis Mourned by St Clare,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc23.jpg,270 x 230 cm
Giotto,St Francis Preaching before Honorius III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc17.jpg,270 x 230 cm
Giotto,St Francis before the Sultan,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc11.jpg,270 x 230 cm
Giotto,Verification of the Stigmata,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc22.jpg,270 x 230 cm
Giotto,Vision of the Flaming Chariot,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc08.jpg,270 x 230 cm
Giotto,the seven virtues,https://www.wga.hu/art/g/giotto/padova/7vicevir/virtu_1.jpg,120 x 60 cm
Gustav Klimt,Portrait of Adele Bloch-Bauer I,https://www.wga.hu/art/k/klimt/2/33portrait.jpg,140 x 140 cm
Gustav Klimt,Stenberg Brothers Exhibition Poster,https://www.wga.hu/art/k/klimt/2/21poster.jpg,970 x 700 mm
Gustav Klimt,The Kiss,https://www.wga.hu/art/k/klimt/2/24kiss.jpg,180 x 180 cm
Gustav Klimt,The Tree of Life,https://www.wga.hu/art/k/klimt/2/42stoclet.jpg,200 x 102 cm
Gustave Courbet,A Burial at Ornans,https://www.wga.hu/art/c/courbet/1/courb104.jpg,315 x 668 cm
Gustave Courbet,Lot and his daughters,https://www.wga.hu/art/c/courbet/1/courb101.jpg,89 x 116 cm
Gustave Courbet,Man with a Pipe,https://www.wga.hu/art/c/courbet/2/courb223.jpg,56 x 47 cm
Gustave Courbet,Sleep,https://www.wga.hu/art/c/courbet/3/courb303.jpg,135 x 200 cm
Gustave Courbet,The Origin of the World,https://www.wga.hu/art/c/courbet/3/courb304.jpg,46 x 55 cm
Henri de Toulouse-Lautrec,At the Moulin Rouge,https://www.wga.hu/art/t/toulouse/3/2mouli04.jpg,80 x 60 cm
Henri de Toulouse-Lautrec,In Bed The Kiss,https://www.wga.hu/art/t/toulouse/3/3bed2.jpg,53 x 34 cm
Henri de Toulouse-Lautrec,Jane Avril,https://www.wga.hu/art/t/toulouse/6/litho25.jpg,56 x 34 cm
Henri de Toulouse-Lautrec,Moulin Rouge La Goulue,https://www.wga.hu/art/t/toulouse/6/litho01.jpg,191 x 117 cm
Henri de Toulouse-Lautrec,Portrait of Vincent van Gogh,https://www.wga.hu/art/t/toulouse/2/1misc01.jpg,54 x 45 cm
Henri de Toulouse-Lautrec,Yvette Guilbert,https://www.wga.hu/art/t/toulouse/4/cabare17.jpg,56 x 48 cm
Hieronymus Bosch,Christ Child with a Walking Frame,https://www.wga.hu/art/b/bosch/1early/13reverse.jpg,
Hieronymus Bosch,Crucifixion with a Donor,https://www.wga.hu/art/b/bosch/1early/08crucif.jpg,"74,7 x 61 cm"
Hieronymus Bosch,Death and the Miser,https://www.wga.hu/art/b/bosch/5panels/10deathm.jpg,93 x 31 cm
Hieronymus Bosch,Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
Hieronymus Bosch,Head of a Halberdier,https://www.wga.hu/art/b/bosch/1early/02fragme.jpg,28 x 20 cm
Hieronymus Bosch,The Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
Hieronymus Bosch,Two Male Heads,https://www.wga.hu/art/b/bosch/1early/03heads.jpg,"14,5 x 12 cm"
Hieronymus Bosch,Two monsters,https://www.wga.hu/art/b/bosch/93graph/28graphi.jpg,164 x 116 mm
Hieronymus Bosch,Visions of the Hereafter Fall of the Damned into Hell,https://www.wga.hu/art/b/bosch/6venice/2hell1.jpg,"86,5 x 39,5 cm"
Honoré Daumier,Rue Transnonain,https://www.wga.hu/art/d/daumier/11transn.jpg,290 x 445 mm
Honoré Daumier,The Uprising,https://www.wga.hu/art/d/daumier/08uprisi.jpg,88 x 113 cm
Honoré Daumier,Third-Class Carriage,https://www.wga.hu/art/d/daumier/093rdcla.jpg,65 x 90 cm
J. M. W. Turner,Dido Building Carthage,https://www.wga.hu/art/t/turner/1/107turnd.jpg,156 x 230 cm
J. M. W. Turner,Rain Steam and Speed,https://www.wga.hu/art/t/turner/2/205turne.jpg,91 x 122 cm
J. M. W. Turner,Snow Storm Steam-Boat off a Harbour s Mouth,https://www.wga.hu/art/t/turner/2/2040turn.jpg,91 x 122 cm
J. M. W. Turner,The Fighting Temeraire,https://www.wga.hu/art/t/turner/2/201turne.jpg,91 x 122 cm
J. M. W. Turner,Ulysses Deriding Polyphemus,https://www.wga.hu/art/t/turner/2/209turne.jpg,132 x 203 cm
Jacques-Louis David,Leonidas at Thermopylae,https://www.wga.hu/art/d/david_j/4/414david.jpg,395 x 531 cm
Jacques-Louis David,Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
Jacques-Louis David,Portrait of the Artist Holding a Thistle,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
Jacques-Louis David,Portrait of the artist s father,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
Jacques-Louis David,The Death of Marat,https://www.wga.hu/art/d/david_j/3/301david.jpg,162 x 128 cm
Jacques-Louis David,The Intervention of the Sabine Women,https://www.wga.hu/art/d/david_j/3/311david.jpg,385 x 522 cm
Jacques-Louis David,The Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
Jan van Eyck,A Young Man in a scarlet turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
Jan van Eyck,Adoration of the Lamb from the Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l3adora.jpg,"137,7 x 242,3 cm"
Jan van Eyck,Arnolfini Portrait,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
Jan van Eyck,Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
Jan van Eyck,Madonna Enthroned,https://www.wga.hu/art/e/eyck_van/jan/02page/23suckli.jpg,"65,5 x 49,5 cm"
Jan van Eyck,Madonna at the Fountain,https://www.wga.hu/art/e/eyck_van/jan/02page/29founta.jpg,19 x 12 cm
Jan van Eyck,Madonna with Canon Joris van der Paele,https://www.wga.hu/art/e/eyck_van/jan/21paele/21paele.jpg,122 x 157 cm
Jan van Eyck,Man in a Red Turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
Jan van Eyck,Portrait of Baudouin de Lannoy,https://www.wga.hu/art/e/eyck_van/jan/02page/19lannoy.jpg,26 x 20 cm
Jan van Eyck,Portrait of Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/111alber.jpg,212 x 180 mm
Jan van Eyck,Portrait of Giovanni di Nicolao Arnolfini,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
Jan van Eyck,Portrait of Jan de Leeuw,https://www.wga.hu/art/e/eyck_van/jan/02page/22leeuw.jpg,"24,5 x 19 cm"
Jan van Eyck,Portrait of Margareta van Eyck,https://www.wga.hu/art/e/eyck_van/jan/02page/28margar.jpg,"32,6 x 25,8 cm"
Jan van Eyck,Portrait of a Man with Carnation,https://www.wga.hu/art/e/eyck_van/jan/02page/17carnat.jpg,40 x 31 cm
Jan van Eyck,Prophet Zacharias Angel of The Annunciation,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed1/u1annun.jpg,"164,8 x 71,7 cm"
Jan van Eyck,Singing Angels,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open2/u2singi.jpg,"164,5 x 71,5 cm"
Jan van Eyck,Study for Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/11alberg.jpg,"34,1 x 27,3 cm"
Jan van Eyck,The Ghent Altarpiece The Donor,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed2/l1donor.jpg,"149,1 x 54,1 cm"
Jan van Eyck,The Ghent Altarpiece wings closed,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
Jan van Eyck,The Just Judges,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l1judge.jpg,145 x 51 cm
Jan van Eyck,The Madonna in the Church,https://www.wga.hu/art/e/eyck_van/jan/01page/04church.jpg,32 x 14 cm
Jean-Antoine Watteau,F tes Venitiennes,https://www.wga.hu/art/w/watteau/antoine/2/15fetesv.jpg,56 x 46 cm
Jean-Antoine Watteau,Gilles,https://www.wga.hu/art/w/watteau/antoine/2/17gilles.jpg,"184,5 x 149,5 cm"
Jean-Antoine Watteau,Mezzetin,https://www.wga.hu/art/w/watteau/antoine/2/141mezze.jpg,"55,2 x 43,2 cm"
Jean-Antoine Watteau,Pilgrimage to Cythera,https://www.wga.hu/art/w/watteau/antoine/1/08cythe2.jpg,129 x 194 cm
Jean-Antoine Watteau,The Dance,https://www.wga.hu/art/w/watteau/antoine/2/19dance.jpg,97 x 116 cm
Jean-Auguste-Dominique Ingres,Grande Odalisque,https://www.wga.hu/art/i/ingres/05ingres.jpg,91 x 162 cm
Jean-Auguste-Dominique Ingres,Jupiter and Thetis,https://www.wga.hu/art/i/ingres/03ingrex.jpg,320 x 260 cm
Jean-Auguste-Dominique Ingres,Napoleon on His Imperial Throne,https://www.wga.hu/art/i/ingres/010ingre.jpg,259 x 162 cm
Jean-Auguste-Dominique Ingres,Oedipus and the Sphinx,https://www.wga.hu/art/i/ingres/071ingre.jpg,189 x 144 cm
Jean-Auguste-Dominique Ingres,The Turkish Bath,https://www.wga.hu/art/i/ingres/17ingres.jpg,
Jean-Baptiste-Camille Corot,Bust of a Young Woman,https://www.wga.hu/art/c/corot/corot07.jpg,53 x 40 cm
Jean-Baptiste-Camille Corot,The Artist s Studio,https://www.wga.hu/art/c/corot/corot163.jpg,56 x 46 cm
Jean-François Millet,Haystacks Autumn,https://www.wga.hu/art/m/millet/06autumn.jpg,85 x 110 cm
Jean-François Millet,The Angelus,https://www.wga.hu/art/m/millet/04angel.jpg,56 x 66 cm
Jean-François Millet,The Gleaners,https://www.wga.hu/art/m/millet/03gleaner.jpg,"85,5 x 111 cm"
Jean-Honoré Fragonard,A Young Girl Reading,https://www.wga.hu/art/f/fragonar/father/2/04readin.jpg,81 x 65 cm
Jean-Honoré Fragonard,The Progress of Love,https://www.wga.hu/art/f/fragonar/father/2/011pursui.jpg,318 x 216 cm
Jean-Honoré Fragonard,The Stolen Kiss,https://www.wga.hu/art/f/fragonar/father/2/11stolen.jpg,45 x 55 cm
Jean-Honoré Fragonard,The Swing,https://www.wga.hu/art/f/fragonar/father/1/021seesa.jpg,120 x 95 cm
John Constable,The Hay Wain,https://www.wga.hu/art/c/constabl/haywain.jpg,130 x 185 cm
John Constable,The Lock,https://www.wga.hu/art/c/constabl/lock.jpg,142 x 120 cm
Leonardo da Vinci,Allegory with a Wolf and an Eagle,https://www.wga.hu/art/l/leonardo/09study/12allego.jpg,170 x 280 mm
Leonardo da Vinci,Anatomical Deawing of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
Leonardo da Vinci,Anatomical Drawings of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
Leonardo da Vinci,Anatomical Studies of a Bear s Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Blood Supply to the Liver,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Bones of the Foot and Study of the Shoulder,https://www.wga.hu/art/l/leonardo/10anatom/3should1.jpg,289 x 199 mm
Leonardo da Vinci,Anatomical Studies of the Developing Foetus,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Facial Nerves,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Hand,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Anatomical Studies of the Human Skeleton,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
Leonardo da Vinci,Madonna Lactans,https://www.wga.hu/art/l/leonardo/03/3litta.jpg,42 x 33 cm
Leonardo da Vinci,Mona Lisa,https://www.wga.hu/art/l/leonardo/04/0monalis.jpg,77 x 53 cm
Leonardo da Vinci,The Last Supper,https://www.wga.hu/art/l/leonardo/03/4lastsu1.jpg,460 x 880 cm
Masaccio,Baptism of the Neophytes,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/baptism.jpg,255 x 162 cm
Masaccio,Madonna and Child Enthroned and Twelve Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
Masaccio,Madonna and Child with Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
Masaccio,Madonna and Child with Five Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
Masaccio,Raising of the Son of Teophilus and St Peter Enthroned,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/theo_pet.jpg,230 x 598 cm
Masaccio,Saints Jerome and John the Baptist,https://www.wga.hu/art/m/masaccio/z_panels/jerome.jpg,125 x 59 cm
Masaccio,San Giovenale Triptych,https://www.wga.hu/art/m/masaccio/z_panels/giovena1.jpg,110 x 65 cm
Masaccio,St Peter Healing the Sick with His Shadow,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/shadow.jpg,230 x 162 cm
Masaccio,The Distribution of Alms and Death of Ananias,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/distrib.jpg,230 x 162 cm
Masaccio,The agony of Christ in the garden of Gethsemane St Jerome as penitent,https://www.wga.hu/art/m/masaccio/z_panels/garden.jpg,62 x 44 cm
Michelangelo,Cumaean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/05_6si3.jpg,375 x 380 cm
Michelangelo,Delphic Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/01_7si1.jpg,350 x 380 cm
Michelangelo,Doni Tondo,https://www.wga.hu/art/m/michelan/2paintin/1/1donitop.jpg,
Michelangelo,Erythraean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
Michelangelo,Erythraean Sibyl and arched window with a view,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
Michelangelo,Jacob and Joseph,https://www.wga.hu/art/m/michelan/3sistina/6lunette/02/lu02jac.jpg,215 x 430 cm
Michelangelo,Judith and Holofernes,https://www.wga.hu/art/m/michelan/3sistina/5spandre/00_1pe1.jpg,570 x 970 cm
Michelangelo,Studies of a Horse with Two Nude Riders and a Male Torso,https://www.wga.hu/art/m/michelan/4drawing/02/25cascin.jpg,222 x 198 mm
Michelangelo,The Ancestors of Christ Zerubbabel Abiud and Eliakim,https://www.wga.hu/art/m/michelan/3sistina/6lunette/05/lu05zer.jpg,215 x 430 cm
Michelangelo,The Creation,https://www.wga.hu/art/m/michelan/3sistina/1genesis/5eve/05_2ce5f.jpg,
Michelangelo,The Creation of Adam,https://www.wga.hu/art/m/michelan/3sistina/1genesis/6adam/06_3ce6.jpg,280 x 570 cm
Michelangelo,The Deluge,https://www.wga.hu/art/m/michelan/3sistina/1genesis/2flood/02_3ce2.jpg,280 x 570 cm
Michelangelo,The Drunkenness of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/1drunken/01_2ce1f.jpg,
Michelangelo,The Sacrifice of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/3sacrifi/03_2ce3f.jpg,
Michelangelo,Three Standing Men in Wide Cloaks Turned to the Left,https://www.wga.hu/art/m/michelan/4drawing/01/03early.jpg,292 x 200 mm
Nicolas Poussin,Rape of the Sabine Women,https://www.wga.hu/art/p/poussin/2a/05sabin1.jpg,"154,6 x 209,9 cm"
Odilon Redon,Closed Eyes,https://www.wga.hu/art/r/redon/closedey.jpg,44 x 36 cm
Parmigianino,Pallas Athena,https://www.wga.hu/art/p/parmigia/1/pallas_a.jpg,"63,8 x 45,1 cm"
Parmigianino,Self-Portrait in a Convex Mirror,https://www.wga.hu/art/p/parmigia/1/convex.jpg,
Parmigianino,Virgin and Child with an Angel,https://www.wga.hu/art/p/parmigia/1/virgin_a.jpg,26 x 19 cm
Parmigianino,Vision of Saint Jerome,https://www.wga.hu/art/p/parmigia/1/v_jerome.jpg,343 x 149 cm
Paul Cézanne,Apples and Oranges,https://www.wga.hu/art/c/cezanne/4/4still6.jpg,74 x 93 cm
Paul Cézanne,Houses at l'Estaque,https://www.wga.hu/art/c/cezanne/2/1lands09.jpg,42 x 59 cm
Paul Cézanne,Mont Sainte-Victoire,https://www.wga.hu/art/c/cezanne/3/1lands09.jpg,67 x 92 cm
Paul Cézanne,The Card Players,https://www.wga.hu/art/c/cezanne/4/2figure1.jpg,48 x 57 cm
Paul Gauguin,Nevermore,https://www.wga.hu/art/g/gauguin/06/tahiti63.jpg,60 x 116 cm
Paul Gauguin,The Yellow Christ,https://www.wga.hu/art/g/gauguin/02/6pould05.jpg,92 x 73 cm
Paul Gauguin,Two Tahitian Women,https://www.wga.hu/art/g/gauguin/06/tahiti72.jpg,94 x 72 cm
Paul Gauguin,Vision After the Sermon,https://www.wga.hu/art/g/gauguin/02/3ponta04.jpg,73 x 92 cm
Paul Gauguin,Where Do We Come From,https://www.wga.hu/art/g/gauguin/06/tahiti66.jpg,141 x 346 cm
Paul Gauguin,Woman with a Hat,https://www.wga.hu/art/g/gauguin/09/graphi16.jpg,150 x 108 mm
Paul Gauguin,Young Girls by the Sea,https://www.wga.hu/art/g/gauguin/04/tahiti23.jpg,68 x 92 cm
Peter Paul Rubens,Assumption of Mary,https://www.wga.hu/art/r/rubens/7graphic/04sketch.jpg,88 x 59 cm
Peter Paul Rubens,Cupid Carving his Bow,https://www.wga.hu/art/r/rubens/21mythol/14mythol.jpg,142 x 108 cm
Peter Paul Rubens,Deer and Monkeys on the reverse of Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
Peter Paul Rubens,Esther before Ahasuerus,https://www.wga.hu/art/r/rubens/7graphic/04sketc.jpg,33 x 32 cm
Peter Paul Rubens,Feast of Herod,https://www.wga.hu/art/r/rubens/15biblic/10feast.jpg,208 x 264 cm
Peter Paul Rubens,Judith and the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
Peter Paul Rubens,Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
Peter Paul Rubens,Music-making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
Peter Paul Rubens,St Francis of Assisi Receiving the Stigmata,https://www.wga.hu/art/r/rubens/14religi/78religi.jpg,264 x 192 cm
Peter Paul Rubens,The Education of the Virgin,https://www.wga.hu/art/r/rubens/13religi/58religi.jpg,193 x 140 cm
Peter Paul Rubens,The Honeysuckle Bower,https://www.wga.hu/art/r/rubens/41portra/08artist.jpg,"178 x 136,5 cm"
Peter Paul Rubens,The Martyrdom of St Dionysius,https://www.wga.hu/art/r/rubens/14religi/73religi.jpg,455 x 347 cm
Peter Paul Rubens,The Virgin Flanked by Music Making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
Peter Paul Rubens,Virgin and Child Enthroned with Four Saints,https://www.wga.hu/art/r/rubens/7graphic/11sketch.jpg,80 x 56 cm
Pierre Puvis de Chavannes,The Poor Fisherman,https://www.wga.hu/art/p/puvis/fisherma.jpg,156 x 193 cm
Pierre-Auguste Renoir,Dance at Le Moulin de la Galette,https://www.wga.hu/art/r/renoir/2/2renoi13.jpg,131 x 175 cm
Pierre-Auguste Renoir,Diana,https://www.wga.hu/art/r/renoir/1/1renoi06.jpg,196 x 130 cm
Pierre-Auguste Renoir,Girl with a Watering Can,https://www.wga.hu/art/r/renoir/2/2renoi19.jpg,100 x 73 cm
Pierre-Auguste Renoir,Houses at Chatou,https://www.wga.hu/art/r/renoir/2/2renoi31.jpg,81 x 100 cm
Pierre-Auguste Renoir,Luncheon of the Boating Party,https://www.wga.hu/art/r/renoir/3/3renoi11.jpg,130 x 173 cm
Pierre-Auguste Renoir,Maternity,https://www.wga.hu/art/r/renoir/3/3renoi26.jpg,82 x 65 cm
Pierre-Auguste Renoir,The Farmers Lunch,https://www.wga.hu/art/r/renoir/2/2renoi12.jpg,54 x 65 cm
Pierre-Auguste Renoir,Two Sisters On the Terrace,https://www.wga.hu/art/r/renoir/3/3renoi06.jpg,100 x 80 cm
Pontormo,Joseph in Egypt,https://www.wga.hu/art/p/pontormo/1/03josep3.jpg,96 x 109 cm
Pontormo,Supper at Emmaus,https://www.wga.hu/art/p/pontormo/3/04emmaus.jpg,230 x 173 cm
Raphael,Angel with a Tambourine,https://www.wga.hu/art/r/raphael/7drawing/1/34study.jpg,189 x 126 mm
Raphael,Bache Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
Raphael,Baldassare Castiglione,https://www.wga.hu/art/r/raphael/5roma/3/01castig.jpg,82 x 67 cm
Raphael,Bindo Altoviti,https://www.wga.hu/art/r/raphael/5roma/2/01altovi.jpg,60 x 44 cm
Raphael,Cherubs of the Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
Raphael,Coronation of Saint Nicholas from Tolentino,https://www.wga.hu/art/r/raphael/7drawing/1/01study.jpg,409 x 265 mm
Raphael,Half-length portrait of a young woman in profile,https://www.wga.hu/art/r/raphael/7drawing/3/09drawim.jpg,254 x 160 mm
Raphael,Interior of the Pantheon,https://www.wga.hu/art/r/raphael/7drawing/3/08drawin.jpg,277 x 407 mm
Raphael,Justice,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/5/1tondo4.jpg,
Raphael,La Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
Raphael,Lucca Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
Raphael,Madonna and Child Madonna della Seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
Raphael,Madonna del Granduca,https://www.wga.hu/art/r/raphael/2firenze/1/22grand.jpg,84 x 56 cm
Raphael,Madonna della Loggia,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
Raphael,Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
Raphael,Madonna della seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
Raphael,Prophet Isaiah,https://www.wga.hu/art/r/raphael/5roma/1/08isaiah.jpg,250 x 155 cm
Raphael,Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
Raphael,St Michael and the Dragon by Cimabue,https://www.wga.hu/art/r/raphael/2firenze/1/25drago2.jpg,31 x 27 cm
Raphael,Study of a Kneeling Nude Girl for The Entombment,https://www.wga.hu/art/r/raphael/7drawing/1/11study.jpg,230 x 319 mm
Raphael,The School of Athens,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/1/athens.jpg,
Raphael,The Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
Raphael,The Virgin and Child with the Young St John Tondo,https://www.wga.hu/art/r/raphael/7drawing/3/35drawin.jpg,193 x 228 mm
Raphael,Vision of the Trones,https://www.wga.hu/art/r/raphael/4stanze/4constan/3vision.jpg,
Rembrandt,Christ and the Woman Taken in Adultery,https://www.wga.hu/art/r/rembrand/13biblic/32newtes.jpg,84 x 65 cm
Rembrandt,Lucretia,https://www.wga.hu/art/r/rembrand/17histor/09histor.jpg,"105 x 92,5 cm"
Rembrandt,The Anatomy Lesson of Dr Nicolaes Tulp,https://www.wga.hu/art/r/rembrand/26group/01group.jpg,170 x 217 cm
Rembrandt,The Night Watch,https://www.wga.hu/art/r/rembrand/26group/05group.jpg,363 x 437 cm
Rembrandt,The Red Trees,https://www.wga.hu/art/r/rembrand/51etchin/4/212.jpg,213 x 279 mm
Sandro Botticelli,Birth of Christ,https://www.wga.hu/art/b/botticel/21/4birth.jpg,200 x 300 cm
Sandro Botticelli,Fortitude,https://www.wga.hu/art/b/botticel/1early/09fortit.jpg,167 x 87 cm
Sandro Botticelli,Madonna and Child with an Angel,https://www.wga.hu/art/b/botticel/1early/01madonn.jpg,87 x 60 cm
Sandro Botticelli,Madonna in Glory with Seraphim,https://www.wga.hu/art/b/botticel/1early/06glory.jpg,120 x 65 cm
Sandro Botticelli,Madonna of the Rose,https://www.wga.hu/art/b/botticel/21/51madonn.jpg,40 x 28 cm
Sandro Botticelli,Madonna of the Rose Garden,https://www.wga.hu/art/b/botticel/1early/07rosega.jpg,124 x 65 cm
Sandro Botticelli,Portrait of Dante,https://www.wga.hu/art/b/botticel/7portrai/14dante.jpg,"54,7 x 47,5 cm"
Sandro Botticelli,Portrait of a Man with a Medal of Cosimo the Elder,https://www.wga.hu/art/b/botticel/7portrai/04medal.jpg,"57,5 x 44 cm"
Sandro Botticelli,Portrait of a Young Man possibly Giuliano de Medici,https://www.wga.hu/art/b/botticel/7portrai/06medici.jpg,76 x 53 cm
Sandro Botticelli,Predella of the Annunciation,https://www.wga.hu/art/b/botticel/22/70cestel.jpg,150 x 156 cm
Sandro Botticelli,Purgatory,https://www.wga.hu/art/b/botticel/93dante/20purgat.jpg,320 x 470 mm
Sandro Botticelli,St John on Patmos,https://www.wga.hu/art/b/botticel/8smarco/21predel.jpg,21 x 269 cm
Sandro Botticelli,The Birth of Venus,https://www.wga.hu/art/b/botticel/5allegor/29birth.jpg,173 x 279 cm
Sandro Botticelli,The Discovery of the Body of Holofernes,https://www.wga.hu/art/b/botticel/1early/13holofe.jpg,31 x 25 cm
Sandro Botticelli,The Return of Judith to Bethulia,https://www.wga.hu/art/b/botticel/1early/12judith.jpg,31 x 24 cm
Sandro Botticelli,Three Scenes from the Story of Esther,https://www.wga.hu/art/b/botticel/1early/14esther.jpg,48 x 132 cm
Simone Martini,Annunciation,https://www.wga.hu/art/s/simone/6annunci/a_angel.jpg,31 x 22cm
Simone Martini,Annunciation to Mary,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
Simone Martini,Annunciation to Saint Anne,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
Simone Martini,Blessing Christ,https://www.wga.hu/art/s/simone/4altars/1louis/5naples.jpg,76 x 46 cm
Simone Martini,Saint Andrew,https://www.wga.hu/art/s/simone/4altars/6other/4andrew.jpg,57 x 38 cm
Simone Martini,Saint Ansanus,https://www.wga.hu/art/s/simone/4altars/6other/1ansanus.jpg,58 x 38 cm
Théodore Géricault,The Raft of the Medusa,https://www.wga.hu/art/g/gericaul/1/105geric.jpg,491 x 716 cm
Titian,Christ the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
Titian,Orpheus and Eurydice,https://www.wga.hu/art/t/tiziano/08/03orpheu.jpg,39 x 53 cm
Titian,Portrait of Isabella of Portugal,https://www.wga.hu/art/t/tiziano/10/1/13isabel.jpg,117 x 93 cm
Titian,The Birth of Adonis,https://www.wga.hu/art/t/tiziano/08/01birth.jpg,35 x 162 cm
Titian,The Christ Child as the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
Titian,The Legend of Polydoros,https://www.wga.hu/art/t/tiziano/08/02polydo.jpg,35 x 162 cm
Titian,The Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
Titian,Venus Anadyomene,https://www.wga.hu/art/t/tiziano/08/07anadyo.jpg,76 x 57 cm
Titian,Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
Vincent van Gogh,Bal du moulin de la Galette,https://www.wga.hu/art/g/gogh_van/06/paris05.jpg,46 x 38 cm
Vincent van Gogh,Haystacks,https://www.wga.hu/art/g/gogh_van/18/2arles08.jpg,241 x 319 mm
Vincent van Gogh,Saint Paul,https://www.wga.hu/art/g/gogh_van/11/1asylu01.jpg,95 x 76 cm
Vincent van Gogh,Self-Portrait with Bandaged Ear,https://www.wga.hu/art/g/gogh_van/16/selfpo31.jpg,60 x 49 cm
Vincent van Gogh,Sunflowers,https://www.wga.hu/art/g/gogh_van/09/arles63.jpg,92 x 71 cm
Vincent van Gogh,Two Children Are Threatened by a Nightingale,https://www.wga.hu/art/g/gogh_van/15/auvers25.jpg,52 x 46 cm
Vincent van Gogh,Wheatfield with Crows,https://www.wga.hu/art/g/gogh_van/15/auvers33.jpg,"50,5 x 103 cm"
Édouard Manet,A Bar at the Folies-Berg re,https://www.wga.hu/art/m/manet/5/5late09.jpg,96 x 130 cm
Édouard Manet,Luncheon on the Grass,https://www.wga.hu/art/m/manet/1/5dejeun1.jpg,208 x 265 cm
Édouard Manet,Olympia,https://www.wga.hu/art/m/manet/1/4olympi1.jpg,131 x 190 cm
Édouard Manet,The Balcony,https://www.wga.hu/art/m/manet/2/2manet18.jpg,170 x 125 cm
1 artist painting link size
2 Albrecht Dürer Abduction of a Woman https://www.wga.hu/art/d/durer/2/11/1/10abduct.jpg 283 x 423 mm
3 Albrecht Dürer Adam https://www.wga.hu/art/d/durer/1/06/2adam.jpg 209 x 81 cm
4 Albrecht Dürer Apostles Philip and James the Elder https://www.wga.hu/art/d/durer/1/08/6apostl.jpg 46 x 37 cm
5 Albrecht Dürer Christ as the Man of Sorrows https://www.wga.hu/art/d/durer/1/01/05sorrow.jpg 30 x 19 cm
6 Albrecht Dürer Courtyard of Innsbruck Castle With Clouds https://www.wga.hu/art/d/durer/2/16/1/04courty.jpg 335 x 267 mm
7 Albrecht Dürer Courtyard of Innsbruck Castle Without Clouds https://www.wga.hu/art/d/durer/2/16/1/03courty.jpg 335 x 267 mm
8 Albrecht Dürer Eve https://www.wga.hu/art/d/durer/1/06/3eve.jpg 209 x 81 cm
9 Albrecht Dürer Fiesole Altarpiece https://www.wga.hu/art/d/durer/1/06/7heller.jpg 190 x 260 cm
10 Albrecht Dürer Portrait of Barbara Holper https://www.wga.hu/art/d/durer/1/01/01mother.jpg 47 x 38 cm
11 Albrecht Dürer Portrait of an unknown praying man https://www.wga.hu/art/d/durer/2/11/5/11portra.jpg 365 x 258 mm
12 Albrecht Dürer Salvator Mundi with the Child Jesus https://www.wga.hu/art/d/durer/1/05/02salvat.jpg 57 x 46 cm
13 Albrecht Dürer The Circumcision of Christ https://www.wga.hu/art/d/durer/2/12/5virgin/10_life.jpg
14 Albrecht Dürer The Death of Orpheus https://www.wga.hu/art/d/durer/2/11/1/05orpheu.jpg 289 x 225 mm
15 Albrecht Dürer The Passion of Christ https://www.wga.hu/art/d/durer/2/12/7smallp/2/14_small.jpg
16 Albrecht Dürer Three Musicians https://www.wga.hu/art/d/durer/1/04/1jabach2.jpg 94 x 51 cm
17 Albrecht Dürer Wire drawing mill near Nyremberg https://www.wga.hu/art/d/durer/2/16/1/02mill.jpg 286 x 426 mm
18 Alphonse Mucha Gismonda https://www.wga.hu/art/m/mucha/poster1.jpg 213 x 75 cm
19 Andrea del Castagno Last Supper https://www.wga.hu/art/a/andrea/castagno/1_1440s/08lasts1.jpg 453 x 975 cm
20 Andrei Rublev Archangel Michael https://www.wga.hu/art/r/rublyov/deesis2.jpg 158 x 106 cm
21 Andrei Rublev Holy Trinity https://www.wga.hu/art/r/rublyov/trinity.jpg 142 x 114 cm
22 Andrei Rublev Hospitality of Abraham https://www.wga.hu/art/r/rublyov/trinity.jpg 142 x 114 cm
23 Andrei Rublev Icon with the Eastern Orthodox Trinity https://www.wga.hu/art/r/rublyov/trinity.jpg 142 x 114 cm
24 Andrei Rublev Icon with the Old Testament Trinity The Hospitality of Abraham https://www.wga.hu/art/r/rublyov/trinity.jpg 142 x 114 cm
25 Andrei Rublev Trinity https://www.wga.hu/art/r/rublyov/trinity.jpg 142 x 114 cm
26 Angelica Kauffman Self-Portrait of the Artist Hesitating Between the Arts of Music and Painting https://www.wga.hu/art/k/kauffman/self_por.jpg 128 x 94 cm
27 Angelica Kauffman Venus Induces Helen to Fall in Love with Paris https://www.wga.hu/art/k/kauffman/venushel.jpg 102 x 128 cm
28 Arnold Böcklin Isle of the Dead https://www.wga.hu/art/b/bocklin/isle1.jpg 111 x 155 cm
29 Artemisia Gentileschi Judith Slaying Holofernes https://www.wga.hu/art/g/gentiles/artemisi/judit.jpg 159 x 126 cm
30 Artemisia Gentileschi Judith and Her Maidservant https://www.wga.hu/art/g/gentiles/artemisi/judith_m.jpg 114 x 93.5 cm
31 Artemisia Gentileschi Self-Portrait as the Allegory of Painting https://www.wga.hu/art/g/gentiles/artemisi/selfport.jpg 96,5 x 73,7 cm
32 Aubrey Beardsley The Dancer s Reward https://www.wga.hu/art/b/beardsle/beards04.jpg 342 x 272 mm
33 Aubrey Beardsley The Yellow Book https://www.wga.hu/art/b/beardsle/beards07.jpg
34 Bronzino Holy Family with St Anne and the Infant St John https://www.wga.hu/art/b/bronzino/3/holyfam.jpg 127 x 102 cm
35 Bronzino Miracle of the Spring https://www.wga.hu/art/b/bronzino/3a/05eleono.jpg
36 Bronzino Portrait of Cosimo I de Medici in Armour https://www.wga.hu/art/b/bronzino/1/cosimo1a.jpg 118 x 99 cm
37 Bronzino Portrait of Eleonora di Toledo with her son Giovanni https://www.wga.hu/art/b/bronzino/1/eleonorb.jpg 59 x 46 cm
38 Bronzino Venus Cupid Folly and Time https://www.wga.hu/art/b/bronzino/4/venus_cu.jpg 147 x 117 cm
39 Camille Pissarro Boulevard Montmartre https://www.wga.hu/art/p/pissarro/camille/2/paris03.jpg 53 x 65 cm
40 Camille Pissarro Jalais Hill Pontoise https://www.wga.hu/art/p/pissarro/camille/1/ponto01.jpg 89 x 116 cm
41 Camille Pissarro Lordship Lane Station https://www.wga.hu/art/p/pissarro/camille/4/variou2.jpg 45 x 73 cm
42 Camille Pissarro Red Roofs https://www.wga.hu/art/p/pissarro/camille/3/winter15.jpg 54 x 65 cm
43 Caravaggio Calling of Saint Matthew https://www.wga.hu/art/c/caravagg/04/23conta.jpg 322 x 340 cm
44 Caravaggio David with the Head of Goliath https://www.wga.hu/art/c/caravagg/08/52david.jpg 91 x 116 cm
45 Caravaggio Madonna di Crevole https://www.wga.hu/art/c/caravagg/07/42loreto.jpg 260 x 150 cm
46 Caravaggio Mary Magdalene https://www.wga.hu/art/c/caravagg/02/12magda.jpg 123 x 99 cm
47 Caravaggio Saint John the Baptist https://www.wga.hu/art/c/caravagg/10/62behead.jpg 361 x 520 cm
48 Caravaggio Saint Matthew https://www.wga.hu/art/c/caravagg/04/23conta.jpg 322 x 340 cm
49 Caravaggio Saint Thomas https://www.wga.hu/art/c/caravagg/06/34thomas.jpg 107 x 146 cm
50 Caravaggio The Calling of Saint Matthew https://www.wga.hu/art/c/caravagg/04/23conta.jpg 322 x 340 cm
51 Caravaggio The Crucifixion of Saint Peter https://www.wga.hu/art/c/caravagg/05/28ceras.jpg 230 x 175 cm
52 Caravaggio The Crucifixion of Saint Peter and the Decapitation of Saint John the Baptist https://www.wga.hu/art/c/caravagg/05/28ceras.jpg 230 x 175 cm
53 Caspar David Friedrich Wanderer above the Sea of Fog https://www.wga.hu/art/f/friedric/2/209fried.jpg 95 x 75 cm
54 Cimabue Madonna Enthroned with the Child St Francis and Four Angels https://www.wga.hu/art/c/cimabue/madonna/madonn_1.jpg 320 x 340 cm
55 Cimabue Madonna and Child Enthroned with Angels and Prophets Santa Trinit Maest https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg 384 x 223 cm
56 Cimabue Santa Trinita Maestà https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg 384 x 223 cm
57 Claude Lorrain Embarkation of the Queen of Sheba https://www.wga.hu/art/c/claude/2/10sheba.jpg 148 x 194 cm
58 Claude Monet Impression, Sunrise https://www.wga.hu/art/m/monet/03/0impress.jpg 48 x 63 cm
59 Claude Monet Rouen Cathedral https://www.wga.hu/art/m/monet/07/5cathed3.jpg 100 x 65 cm
60 Claude Monet The Japanese Bridge https://www.wga.hu/art/m/monet/08/3bridge5.jpg 89 x 116 cm
61 Claude Monet The Lunch https://www.wga.hu/art/m/monet/02/1vario02.jpg 230 x 150 cm
62 Claude Monet Water Lilies https://www.wga.hu/art/m/monet/08/7liliy01.jpg 90 x 100 cm
63 Diego Velázquez Christ in the House of Martha and Mary https://www.wga.hu/art/v/velazque/01/0108vela.jpg 60 x 103,5 cm
64 Diego Velázquez Kitchen Scene https://www.wga.hu/art/v/velazque/01/0106vela.jpg 55 x 118 cm
65 Diego Velázquez Kitchen Scene with the Supper in Emmaus https://www.wga.hu/art/v/velazque/01/0106vela.jpg 55 x 118 cm
66 Diego Velázquez Las Meninas https://www.wga.hu/art/v/velazque/08/0801vela.jpg 318 x 276 cm
67 Diego Velázquez Philip IV of Spain 1605 1665 https://www.wga.hu/art/v/velazque/02/0204vela.jpg 210 x 102 cm
68 Diego Velázquez Portrait du Conde-Duque de Olivares https://www.wga.hu/art/v/velazque/05/0508vela.jpg 314 x 240 cm
69 Diego Velázquez Portrait of Philip IV of Spain https://www.wga.hu/art/v/velazque/10/1002vela.jpg 47 x 37,5 cm
70 Diego Velázquez Queen Mariana of Spain 1634 1696 https://www.wga.hu/art/v/velazque/10/1003vela.jpg 231 x 131 cm
71 Diego Velázquez The Buffoon Pablo de Valladolid https://www.wga.hu/art/v/velazque/06/0604vela.jpg 214 x 125 cm
72 Diego Velázquez The Dwarf Francisco Lezcano https://www.wga.hu/art/v/velazque/07/0702vela.jpg 107 x 83 cm
73 Domenico Ghirlandaio Birth of the Virgin https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/2birth.jpg
74 Domenico Ghirlandaio Confirmation of the Rule https://www.wga.hu/art/g/ghirland/domenico/5sassett/frescoes/5confir.jpg
75 Domenico Ghirlandaio Expulsion of Joachim from the Temple https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/1expuls.jpg
76 Domenico Ghirlandaio Madonna of Mercy and Lamentation https://www.wga.hu/art/g/ghirland/domenico/1early/2ogniss.jpg
77 Domenico Ghirlandaio Marriage of Mary https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/4marria.jpg
78 Domenico Ghirlandaio Portrait of Giovanna Tornabuoni https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg 76 x 50 cm
79 Domenico Ghirlandaio Portrait of Lucrezia Tornabuoni https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg 76 x 50 cm
80 Domenico Ghirlandaio Saint John the Evangelist on the Island of Patmos https://www.wga.hu/art/g/ghirland/domenico/7panel/02patmos.jpg
81 Duccio Christ Accused by the Pharisees https://www.wga.hu/art/d/duccio/maesta/verso_1/verso12.jpg 49 x 57 cm
82 Duccio Christ and the Samaritan Woman https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_6.jpg 44 x 46 cm
83 Duccio Disputation with the Doctors https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_7.jpg 43 x 43 cm
84 Duccio Entry into Jerusalem https://www.wga.hu/art/d/duccio/maesta/verso_1/verso01.jpg 100 x 57 cm
85 Duccio Gualino Madonna https://www.wga.hu/art/d/duccio/various/22gualin.jpg 157 x 86 cm
86 Duccio Jesus Appears on Lake Tiberias https://www.wga.hu/art/d/duccio/maesta/crown_v/cro_v_3.jpg 37 x 48 cm
87 Duccio Madonna with Child and six Angels https://www.wga.hu/art/d/duccio/various/6perugi.jpg 97 x 63 cm
88 Duccio Maest - Backside https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg 214 x 412 cm
89 Duccio Maestà https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg 214 x 412 cm
90 Duccio Pact of Judas top Christ Taking Leave of the Apostles bottom https://www.wga.hu/art/d/duccio/maesta/verso_1/verso04.jpg 50 x 53 cm
91 Duccio Rucellai Madonna https://www.wga.hu/art/d/duccio/ruccelai/3ruccela.jpg 450 x 290 cm
92 Duccio The Calling of the Apostles Peter and Andrew https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_4.jpg 44 x 46 cm
93 Duccio The Healing of the Man Born Blind https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_7.jpg 45 x 47 cm
94 Duccio The Madonna of the Franciscans https://www.wga.hu/art/d/duccio/various/51franci.jpg 23,5 x 16 cm
95 Duccio The Nativity with the Prophets Isaiah and Ezekiel https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_1d.jpg 48 x 87 cm
96 Duccio The Temptation of Christ on the Mountain https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_3.jpg 43 x 46 cm
97 Duccio The Wedding at Cana https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_5.jpg 44 x 47 cm
98 Duccio The three Marys at the Tomb https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg 51 x 54 cm
99 Duccio Three Marys at the Tomb https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg 51 x 54 cm
100 Duccio Three Marys at the Tomb top Descent into Hell bottom https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg 51 x 54 cm
101 Edgar Degas Blue Dancer https://www.wga.hu/art/d/degas/5/1890s_04.jpg 67 x 67 cm
102 Edgar Degas Blue Dancers https://www.wga.hu/art/d/degas/5/1890s_04.jpg 67 x 67 cm
103 Edgar Degas The Ballet Class https://www.wga.hu/art/d/degas/4/1880s_03.jpg 82 x 77 cm
104 Edvard Munch Anxiety https://www.wga.hu/art/m/munch/scream5.jpg 94 x 74 cm
105 Edvard Munch Madonna https://www.wga.hu/art/m/munch/madonna1.jpg 91 x 71 cm
106 Edvard Munch The Dance of Life https://www.wga.hu/art/m/munch/stages2.jpg 125 x 191 cm
107 Edvard Munch The Scream https://www.wga.hu/art/m/munch/scream1.jpg 91 x 74 cm
108 Edvard Munch The Sick Child https://www.wga.hu/art/m/munch/sick1.jpg 120 x 119 cm
109 El Greco Disrobement of Christ https://www.wga.hu/art/g/greco_el/04/0401grec.jpg
110 El Greco Dormition of the Virgin https://www.wga.hu/art/g/greco_el/01/0101grec.jpg 61,4 x 45 cm
111 El Greco Ecstasy of St Francis https://www.wga.hu/art/g/greco_el/06/0610grec.jpg 102 x 75 cm
112 El Greco Miracle of Saint Peter https://www.wga.hu/art/g/greco_el/20/2005grec.jpg 209 x 106 cm
113 El Greco Saint Francis reveives the stigmata https://www.wga.hu/art/g/greco_el/08/0804grec.jpg 105 x 80 cm
114 El Greco Saint Joseph https://www.wga.hu/art/g/greco_el/06/0613grec.jpg 68 x 56 cm
115 El Greco Saint Peter https://www.wga.hu/art/g/greco_el/20/2005grec.jpg 209 x 106 cm
116 El Greco Saint Peter Weeping https://www.wga.hu/art/g/greco_el/20/2005grec.jpg 209 x 106 cm
117 El Greco Studies for Martyrdom of Saint Peter the Martyr https://www.wga.hu/art/g/greco_el/20/2005grec.jpg 209 x 106 cm
118 El Greco The Penitent Saint Jerome https://www.wga.hu/art/g/greco_el/21/2104grec.jpg 166 x 110 cm
119 El Greco The Stigmata of Saint Francis https://www.wga.hu/art/g/greco_el/08/0804grec.jpg 105 x 80 cm
120 El Greco View of Toledo https://www.wga.hu/art/g/greco_el/11/1104grec.jpg 121 x 109 cm
121 El Greco the repose of the Virgin Mary https://www.wga.hu/art/g/greco_el/10/1010grec.jpg 52 x 36 cm
122 Eugène Delacroix Arab Horses Fighting in a Stable https://www.wga.hu/art/d/delacroi/5/512delac.jpg 64,6 x 81 cm
123 Eugène Delacroix Liberty Leading the People https://www.wga.hu/art/d/delacroi/2/208delac.jpg 260 x 325 cm
124 Eugène Delacroix The Death of Sardanapalus https://www.wga.hu/art/d/delacroi/2/204delac.jpg 392 x 496 cm
125 Eugène Delacroix The Massacre at Chios https://www.wga.hu/art/d/delacroi/1/107delac.jpg 419 x 354 cm
126 Eugène Delacroix Women of Algiers in their Apartment https://www.wga.hu/art/d/delacroi/3/307delac.jpg 180 x 229 cm
127 Fra Angelico Apparition at Arles https://www.wga.hu/art/a/angelico/12/02compa4.jpg 26 x 31 cm
128 Fra Angelico Birth of the Virgin https://www.wga.hu/art/a/angelico/04/2edge1.jpg 23 x 14 cm
129 Fra Angelico Compagnia di San Francesco altarpiece https://www.wga.hu/art/a/angelico/12/01compag.jpg 189 x 81 cm
130 Fra Angelico Deposition from the Cross https://www.wga.hu/art/a/angelico/08/1trinita.jpg 176 x 185 cm
131 Fra Angelico Saint Francis and a Bishop Saint https://www.wga.hu/art/a/angelico/00/11fieso7.jpg
132 Fra Angelico Sant Ambrogio Altarpiece https://www.wga.hu/art/a/angelico/07/altar_sm.jpg 220 x 227 cm
133 Fra Angelico The Annunciation https://www.wga.hu/art/a/angelico/09/cells/03_annun.jpg 176 x 148 cm
134 Fra Angelico The Funeral of a Bishop Saint https://www.wga.hu/art/a/angelico/00/11fieso7.jpg
135 Fra Angelico The Meeting of Saint Dominic and Saint Francis of Assisi https://www.wga.hu/art/a/angelico/13/03prede4.jpg 27 x 26 cm
136 Francisco Goya Dream of St Gregory https://www.wga.hu/art/g/goya/4/403goya.jpg 188 x 113 cm
137 Francisco Goya Portrait of Andr Derain https://www.wga.hu/art/g/goya/4/407goya.jpg 95 x 65,7 cm
138 Francisco Goya Portrait of the Duke of Wellington https://www.wga.hu/art/g/goya/7/707goya.jpg 64 x 52 cm
139 Francisco Goya The Clothed Maja https://www.wga.hu/art/g/goya/5/502goya.jpg 97 x 190 cm
140 Francisco Goya The Naked Maja https://www.wga.hu/art/g/goya/5/501goya.jpg 97 x 190 cm
141 Francisco Goya The Third of May 1808 https://www.wga.hu/art/g/goya/7/714goya.jpg 266 x 345 cm
142 Francisco Goya Witches Sabbath https://www.wga.hu/art/g/goya/2/218goya.jpg 43 x 30 cm
143 François Boucher Madame de Pompadour https://www.wga.hu/art/b/boucher/2/pompado2.jpg 72,5 x 57 cm
144 François Boucher The Blonde Odalisque https://www.wga.hu/art/b/boucher/2/o_murph.jpg 59 x 73 cm
145 François Boucher The Toilet of Venus https://www.wga.hu/art/b/boucher/2/venus_to.jpg 108 x 85 cm
146 Georges Seurat Bathers at Asni res https://www.wga.hu/art/s/seurat/figures/bathers1.jpg 201 x 300 cm
147 Georges Seurat Models https://www.wga.hu/art/s/seurat/figures/models.jpg 39 x 49 cm
148 Georges Seurat Sunday Afternoon on the Island of La Grande Jatte https://www.wga.hu/art/s/seurat/figures/jatte.jpg 208 x 308 cm
149 Georges Seurat The Circus https://www.wga.hu/art/s/seurat/figures/zcircus1.jpg 180 x 148 cm
150 Georges Seurat The Lighthouse at Honfleur https://www.wga.hu/art/s/seurat/landscap/honfleu3.jpg 67 x 82 cm
151 Georges Seurat Young Woman Powdering Herself https://www.wga.hu/art/s/seurat/figures/powderin.jpg 96 x 80 cm
152 Giorgione A Woman Spinning and an Old Woman https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg 68 x 59 cm
153 Giorgione Old Woman Cooking Eggs https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg 68 x 59 cm
154 Giorgione Sleeping Venus https://www.wga.hu/art/g/giorgion/various/venus.jpg 108 x 175 cm
155 Giorgione The Pastoral Concert https://www.wga.hu/art/g/giorgion/various/concert.jpg 110 x 138 cm
156 Giotto Apparition to Fra Agostino and to Bishop Guido of Arezzo https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc21.jpg 270 x 230 cm
157 Giotto Badia Polyptych https://www.wga.hu/art/g/giotto/z_panel/2panel/5badia.jpg 91x334 cm
158 Giotto Canonization of St Francis https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc24.jpg 270 x 230 cm
159 Giotto Carnesecchi Triptych https://www.wga.hu/art/g/giotto/z_panel/4stefane/10stefan.jpg 220 x 245 cm
160 Giotto Death and Ascension of St Francis https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc20.jpg 270 x 230 cm
161 Giotto Death of the Knight of Celano https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc16.jpg 270 x 230 cm
162 Giotto Descent into Hell https://www.wga.hu/art/g/giotto/z_panel/3polypty/6limbo.jpg 45 x 44 cm
163 Giotto Dream of Innocent III https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc06.jpg 270 x 230 cm
164 Giotto Dream of the Palace https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc03.jpg 270 x 230 cm
165 Giotto Exorcism of the Demons at Arezzo https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc10.jpg 270 x 230 cm
166 Giotto Homage of a Simple Man https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc01.jpg 270 x 230 cm
167 Giotto Institution of the Crib at Greccio https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc13.jpg 270 x 230 cm
168 Giotto Joachim s Dream https://www.wga.hu/art/g/giotto/padova/1joachim/joachi5.jpg 200 x 185 cm
169 Giotto Joachim s Sacrificial Offering https://www.wga.hu/art/g/giotto/padova/1joachim/joachi4.jpg 200 x 185 cm
170 Giotto Miracle of the Crucifix https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc04.jpg 270 x 230 cm
171 Giotto Renunciation of Wordly Goods https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc05.jpg 270 x 230 cm
172 Giotto Saint Francis https://www.wga.hu/art/g/giotto/assisi/upper/05saints.jpg
173 Giotto Scenes from the New Testament Lamentation https://www.wga.hu/art/g/giotto/assisi/upper/new_test/05lamen.jpg 300 x 300 cm
174 Giotto Sermon to the Birds https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc15.jpg 270 x 200 cm
175 Giotto St Francis Giving his Mantle to a Poor Man https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc02.jpg 270 x 230 cm
176 Giotto St Francis Mourned by St Clare https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc23.jpg 270 x 230 cm
177 Giotto St Francis Preaching before Honorius III https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc17.jpg 270 x 230 cm
178 Giotto St Francis before the Sultan https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc11.jpg 270 x 230 cm
179 Giotto Verification of the Stigmata https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc22.jpg 270 x 230 cm
180 Giotto Vision of the Flaming Chariot https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc08.jpg 270 x 230 cm
181 Giotto the seven virtues https://www.wga.hu/art/g/giotto/padova/7vicevir/virtu_1.jpg 120 x 60 cm
182 Gustav Klimt Portrait of Adele Bloch-Bauer I https://www.wga.hu/art/k/klimt/2/33portrait.jpg 140 x 140 cm
183 Gustav Klimt Stenberg Brothers Exhibition Poster https://www.wga.hu/art/k/klimt/2/21poster.jpg 970 x 700 mm
184 Gustav Klimt The Kiss https://www.wga.hu/art/k/klimt/2/24kiss.jpg 180 x 180 cm
185 Gustav Klimt The Tree of Life https://www.wga.hu/art/k/klimt/2/42stoclet.jpg 200 x 102 cm
186 Gustave Courbet A Burial at Ornans https://www.wga.hu/art/c/courbet/1/courb104.jpg 315 x 668 cm
187 Gustave Courbet Lot and his daughters https://www.wga.hu/art/c/courbet/1/courb101.jpg 89 x 116 cm
188 Gustave Courbet Man with a Pipe https://www.wga.hu/art/c/courbet/2/courb223.jpg 56 x 47 cm
189 Gustave Courbet Sleep https://www.wga.hu/art/c/courbet/3/courb303.jpg 135 x 200 cm
190 Gustave Courbet The Origin of the World https://www.wga.hu/art/c/courbet/3/courb304.jpg 46 x 55 cm
191 Henri de Toulouse-Lautrec At the Moulin Rouge https://www.wga.hu/art/t/toulouse/3/2mouli04.jpg 80 x 60 cm
192 Henri de Toulouse-Lautrec In Bed The Kiss https://www.wga.hu/art/t/toulouse/3/3bed2.jpg 53 x 34 cm
193 Henri de Toulouse-Lautrec Jane Avril https://www.wga.hu/art/t/toulouse/6/litho25.jpg 56 x 34 cm
194 Henri de Toulouse-Lautrec Moulin Rouge La Goulue https://www.wga.hu/art/t/toulouse/6/litho01.jpg 191 x 117 cm
195 Henri de Toulouse-Lautrec Portrait of Vincent van Gogh https://www.wga.hu/art/t/toulouse/2/1misc01.jpg 54 x 45 cm
196 Henri de Toulouse-Lautrec Yvette Guilbert https://www.wga.hu/art/t/toulouse/4/cabare17.jpg 56 x 48 cm
197 Hieronymus Bosch Christ Child with a Walking Frame https://www.wga.hu/art/b/bosch/1early/13reverse.jpg
198 Hieronymus Bosch Crucifixion with a Donor https://www.wga.hu/art/b/bosch/1early/08crucif.jpg 74,7 x 61 cm
199 Hieronymus Bosch Death and the Miser https://www.wga.hu/art/b/bosch/5panels/10deathm.jpg 93 x 31 cm
200 Hieronymus Bosch Garden of Earthly Delights https://www.wga.hu/art/b/bosch/3garden/1garden.jpg 220 x 195 cm
201 Hieronymus Bosch Head of a Halberdier https://www.wga.hu/art/b/bosch/1early/02fragme.jpg 28 x 20 cm
202 Hieronymus Bosch The Garden of Earthly Delights https://www.wga.hu/art/b/bosch/3garden/1garden.jpg 220 x 195 cm
203 Hieronymus Bosch Two Male Heads https://www.wga.hu/art/b/bosch/1early/03heads.jpg 14,5 x 12 cm
204 Hieronymus Bosch Two monsters https://www.wga.hu/art/b/bosch/93graph/28graphi.jpg 164 x 116 mm
205 Hieronymus Bosch Visions of the Hereafter Fall of the Damned into Hell https://www.wga.hu/art/b/bosch/6venice/2hell1.jpg 86,5 x 39,5 cm
206 Honoré Daumier Rue Transnonain https://www.wga.hu/art/d/daumier/11transn.jpg 290 x 445 mm
207 Honoré Daumier The Uprising https://www.wga.hu/art/d/daumier/08uprisi.jpg 88 x 113 cm
208 Honoré Daumier Third-Class Carriage https://www.wga.hu/art/d/daumier/093rdcla.jpg 65 x 90 cm
209 J. M. W. Turner Dido Building Carthage https://www.wga.hu/art/t/turner/1/107turnd.jpg 156 x 230 cm
210 J. M. W. Turner Rain Steam and Speed https://www.wga.hu/art/t/turner/2/205turne.jpg 91 x 122 cm
211 J. M. W. Turner Snow Storm Steam-Boat off a Harbour s Mouth https://www.wga.hu/art/t/turner/2/2040turn.jpg 91 x 122 cm
212 J. M. W. Turner The Fighting Temeraire https://www.wga.hu/art/t/turner/2/201turne.jpg 91 x 122 cm
213 J. M. W. Turner Ulysses Deriding Polyphemus https://www.wga.hu/art/t/turner/2/209turne.jpg 132 x 203 cm
214 Jacques-Louis David Leonidas at Thermopylae https://www.wga.hu/art/d/david_j/4/414david.jpg 395 x 531 cm
215 Jacques-Louis David Oath of the Horatii https://www.wga.hu/art/d/david_j/2/200david.jpg 330 x 425 cm
216 Jacques-Louis David Portrait of the Artist Holding a Thistle https://www.wga.hu/art/d/david_j/3/304david.jpg 81 x 64 cm
217 Jacques-Louis David Portrait of the artist s father https://www.wga.hu/art/d/david_j/3/304david.jpg 81 x 64 cm
218 Jacques-Louis David The Death of Marat https://www.wga.hu/art/d/david_j/3/301david.jpg 162 x 128 cm
219 Jacques-Louis David The Intervention of the Sabine Women https://www.wga.hu/art/d/david_j/3/311david.jpg 385 x 522 cm
220 Jacques-Louis David The Oath of the Horatii https://www.wga.hu/art/d/david_j/2/200david.jpg 330 x 425 cm
221 Jan van Eyck A Young Man in a scarlet turban https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg 25,5 x 19 cm
222 Jan van Eyck Adoration of the Lamb from the Ghent Altarpiece https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l3adora.jpg 137,7 x 242,3 cm
223 Jan van Eyck Arnolfini Portrait https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg 29 x 20 cm
224 Jan van Eyck Ghent Altarpiece https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg 350 x 461 cm
225 Jan van Eyck Madonna Enthroned https://www.wga.hu/art/e/eyck_van/jan/02page/23suckli.jpg 65,5 x 49,5 cm
226 Jan van Eyck Madonna at the Fountain https://www.wga.hu/art/e/eyck_van/jan/02page/29founta.jpg 19 x 12 cm
227 Jan van Eyck Madonna with Canon Joris van der Paele https://www.wga.hu/art/e/eyck_van/jan/21paele/21paele.jpg 122 x 157 cm
228 Jan van Eyck Man in a Red Turban https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg 25,5 x 19 cm
229 Jan van Eyck Portrait of Baudouin de Lannoy https://www.wga.hu/art/e/eyck_van/jan/02page/19lannoy.jpg 26 x 20 cm
230 Jan van Eyck Portrait of Cardinal Niccol Albergati https://www.wga.hu/art/e/eyck_van/jan/01page/111alber.jpg 212 x 180 mm
231 Jan van Eyck Portrait of Giovanni di Nicolao Arnolfini https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg 29 x 20 cm
232 Jan van Eyck Portrait of Jan de Leeuw https://www.wga.hu/art/e/eyck_van/jan/02page/22leeuw.jpg 24,5 x 19 cm
233 Jan van Eyck Portrait of Margareta van Eyck https://www.wga.hu/art/e/eyck_van/jan/02page/28margar.jpg 32,6 x 25,8 cm
234 Jan van Eyck Portrait of a Man with Carnation https://www.wga.hu/art/e/eyck_van/jan/02page/17carnat.jpg 40 x 31 cm
235 Jan van Eyck Prophet Zacharias Angel of The Annunciation https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed1/u1annun.jpg 164,8 x 71,7 cm
236 Jan van Eyck Singing Angels https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open2/u2singi.jpg 164,5 x 71,5 cm
237 Jan van Eyck Study for Cardinal Niccol Albergati https://www.wga.hu/art/e/eyck_van/jan/01page/11alberg.jpg 34,1 x 27,3 cm
238 Jan van Eyck The Ghent Altarpiece The Donor https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed2/l1donor.jpg 149,1 x 54,1 cm
239 Jan van Eyck The Ghent Altarpiece wings closed https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg 350 x 461 cm
240 Jan van Eyck The Just Judges https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l1judge.jpg 145 x 51 cm
241 Jan van Eyck The Madonna in the Church https://www.wga.hu/art/e/eyck_van/jan/01page/04church.jpg 32 x 14 cm
242 Jean-Antoine Watteau F tes Venitiennes https://www.wga.hu/art/w/watteau/antoine/2/15fetesv.jpg 56 x 46 cm
243 Jean-Antoine Watteau Gilles https://www.wga.hu/art/w/watteau/antoine/2/17gilles.jpg 184,5 x 149,5 cm
244 Jean-Antoine Watteau Mezzetin https://www.wga.hu/art/w/watteau/antoine/2/141mezze.jpg 55,2 x 43,2 cm
245 Jean-Antoine Watteau Pilgrimage to Cythera https://www.wga.hu/art/w/watteau/antoine/1/08cythe2.jpg 129 x 194 cm
246 Jean-Antoine Watteau The Dance https://www.wga.hu/art/w/watteau/antoine/2/19dance.jpg 97 x 116 cm
247 Jean-Auguste-Dominique Ingres Grande Odalisque https://www.wga.hu/art/i/ingres/05ingres.jpg 91 x 162 cm
248 Jean-Auguste-Dominique Ingres Jupiter and Thetis https://www.wga.hu/art/i/ingres/03ingrex.jpg 320 x 260 cm
249 Jean-Auguste-Dominique Ingres Napoleon on His Imperial Throne https://www.wga.hu/art/i/ingres/010ingre.jpg 259 x 162 cm
250 Jean-Auguste-Dominique Ingres Oedipus and the Sphinx https://www.wga.hu/art/i/ingres/071ingre.jpg 189 x 144 cm
251 Jean-Auguste-Dominique Ingres The Turkish Bath https://www.wga.hu/art/i/ingres/17ingres.jpg
252 Jean-Baptiste-Camille Corot Bust of a Young Woman https://www.wga.hu/art/c/corot/corot07.jpg 53 x 40 cm
253 Jean-Baptiste-Camille Corot The Artist s Studio https://www.wga.hu/art/c/corot/corot163.jpg 56 x 46 cm
254 Jean-François Millet Haystacks Autumn https://www.wga.hu/art/m/millet/06autumn.jpg 85 x 110 cm
255 Jean-François Millet The Angelus https://www.wga.hu/art/m/millet/04angel.jpg 56 x 66 cm
256 Jean-François Millet The Gleaners https://www.wga.hu/art/m/millet/03gleaner.jpg 85,5 x 111 cm
257 Jean-Honoré Fragonard A Young Girl Reading https://www.wga.hu/art/f/fragonar/father/2/04readin.jpg 81 x 65 cm
258 Jean-Honoré Fragonard The Progress of Love https://www.wga.hu/art/f/fragonar/father/2/011pursui.jpg 318 x 216 cm
259 Jean-Honoré Fragonard The Stolen Kiss https://www.wga.hu/art/f/fragonar/father/2/11stolen.jpg 45 x 55 cm
260 Jean-Honoré Fragonard The Swing https://www.wga.hu/art/f/fragonar/father/1/021seesa.jpg 120 x 95 cm
261 John Constable The Hay Wain https://www.wga.hu/art/c/constabl/haywain.jpg 130 x 185 cm
262 John Constable The Lock https://www.wga.hu/art/c/constabl/lock.jpg 142 x 120 cm
263 Leonardo da Vinci Allegory with a Wolf and an Eagle https://www.wga.hu/art/l/leonardo/09study/12allego.jpg 170 x 280 mm
264 Leonardo da Vinci Anatomical Deawing of the Heart and its Blood Vessels https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg
265 Leonardo da Vinci Anatomical Drawings of the Heart and its Blood Vessels https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg
266 Leonardo da Vinci Anatomical Studies of a Bear s Foot https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
267 Leonardo da Vinci Anatomical Studies of the Blood Supply to the Liver https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
268 Leonardo da Vinci Anatomical Studies of the Bones of the Foot and Study of the Shoulder https://www.wga.hu/art/l/leonardo/10anatom/3should1.jpg 289 x 199 mm
269 Leonardo da Vinci Anatomical Studies of the Developing Foetus https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
270 Leonardo da Vinci Anatomical Studies of the Facial Nerves https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
271 Leonardo da Vinci Anatomical Studies of the Foot https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
272 Leonardo da Vinci Anatomical Studies of the Hand https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
273 Leonardo da Vinci Anatomical Studies of the Human Skeleton https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg
274 Leonardo da Vinci Madonna Lactans https://www.wga.hu/art/l/leonardo/03/3litta.jpg 42 x 33 cm
275 Leonardo da Vinci Mona Lisa https://www.wga.hu/art/l/leonardo/04/0monalis.jpg 77 x 53 cm
276 Leonardo da Vinci The Last Supper https://www.wga.hu/art/l/leonardo/03/4lastsu1.jpg 460 x 880 cm
277 Masaccio Baptism of the Neophytes https://www.wga.hu/art/m/masaccio/brancacc/st_peter/baptism.jpg 255 x 162 cm
278 Masaccio Madonna and Child Enthroned and Twelve Angels https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg 136 x 73 cm
279 Masaccio Madonna and Child with Angels https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg 136 x 73 cm
280 Masaccio Madonna and Child with Five Angels https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg 136 x 73 cm
281 Masaccio Raising of the Son of Teophilus and St Peter Enthroned https://www.wga.hu/art/m/masaccio/brancacc/st_peter/theo_pet.jpg 230 x 598 cm
282 Masaccio Saints Jerome and John the Baptist https://www.wga.hu/art/m/masaccio/z_panels/jerome.jpg 125 x 59 cm
283 Masaccio San Giovenale Triptych https://www.wga.hu/art/m/masaccio/z_panels/giovena1.jpg 110 x 65 cm
284 Masaccio St Peter Healing the Sick with His Shadow https://www.wga.hu/art/m/masaccio/brancacc/st_peter/shadow.jpg 230 x 162 cm
285 Masaccio The Distribution of Alms and Death of Ananias https://www.wga.hu/art/m/masaccio/brancacc/st_peter/distrib.jpg 230 x 162 cm
286 Masaccio The agony of Christ in the garden of Gethsemane St Jerome as penitent https://www.wga.hu/art/m/masaccio/z_panels/garden.jpg 62 x 44 cm
287 Michelangelo Cumaean Sibyl https://www.wga.hu/art/m/michelan/3sistina/4sibyls/05_6si3.jpg 375 x 380 cm
288 Michelangelo Delphic Sibyl https://www.wga.hu/art/m/michelan/3sistina/4sibyls/01_7si1.jpg 350 x 380 cm
289 Michelangelo Doni Tondo https://www.wga.hu/art/m/michelan/2paintin/1/1donitop.jpg
290 Michelangelo Erythraean Sibyl https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg 360 x 380 cm
291 Michelangelo Erythraean Sibyl and arched window with a view https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg 360 x 380 cm
292 Michelangelo Jacob and Joseph https://www.wga.hu/art/m/michelan/3sistina/6lunette/02/lu02jac.jpg 215 x 430 cm
293 Michelangelo Judith and Holofernes https://www.wga.hu/art/m/michelan/3sistina/5spandre/00_1pe1.jpg 570 x 970 cm
294 Michelangelo Studies of a Horse with Two Nude Riders and a Male Torso https://www.wga.hu/art/m/michelan/4drawing/02/25cascin.jpg 222 x 198 mm
295 Michelangelo The Ancestors of Christ Zerubbabel Abiud and Eliakim https://www.wga.hu/art/m/michelan/3sistina/6lunette/05/lu05zer.jpg 215 x 430 cm
296 Michelangelo The Creation https://www.wga.hu/art/m/michelan/3sistina/1genesis/5eve/05_2ce5f.jpg
297 Michelangelo The Creation of Adam https://www.wga.hu/art/m/michelan/3sistina/1genesis/6adam/06_3ce6.jpg 280 x 570 cm
298 Michelangelo The Deluge https://www.wga.hu/art/m/michelan/3sistina/1genesis/2flood/02_3ce2.jpg 280 x 570 cm
299 Michelangelo The Drunkenness of Noah https://www.wga.hu/art/m/michelan/3sistina/1genesis/1drunken/01_2ce1f.jpg
300 Michelangelo The Sacrifice of Noah https://www.wga.hu/art/m/michelan/3sistina/1genesis/3sacrifi/03_2ce3f.jpg
301 Michelangelo Three Standing Men in Wide Cloaks Turned to the Left https://www.wga.hu/art/m/michelan/4drawing/01/03early.jpg 292 x 200 mm
302 Nicolas Poussin Rape of the Sabine Women https://www.wga.hu/art/p/poussin/2a/05sabin1.jpg 154,6 x 209,9 cm
303 Odilon Redon Closed Eyes https://www.wga.hu/art/r/redon/closedey.jpg 44 x 36 cm
304 Parmigianino Pallas Athena https://www.wga.hu/art/p/parmigia/1/pallas_a.jpg 63,8 x 45,1 cm
305 Parmigianino Self-Portrait in a Convex Mirror https://www.wga.hu/art/p/parmigia/1/convex.jpg
306 Parmigianino Virgin and Child with an Angel https://www.wga.hu/art/p/parmigia/1/virgin_a.jpg 26 x 19 cm
307 Parmigianino Vision of Saint Jerome https://www.wga.hu/art/p/parmigia/1/v_jerome.jpg 343 x 149 cm
308 Paul Cézanne Apples and Oranges https://www.wga.hu/art/c/cezanne/4/4still6.jpg 74 x 93 cm
309 Paul Cézanne Houses at l'Estaque https://www.wga.hu/art/c/cezanne/2/1lands09.jpg 42 x 59 cm
310 Paul Cézanne Mont Sainte-Victoire https://www.wga.hu/art/c/cezanne/3/1lands09.jpg 67 x 92 cm
311 Paul Cézanne The Card Players https://www.wga.hu/art/c/cezanne/4/2figure1.jpg 48 x 57 cm
312 Paul Gauguin Nevermore https://www.wga.hu/art/g/gauguin/06/tahiti63.jpg 60 x 116 cm
313 Paul Gauguin The Yellow Christ https://www.wga.hu/art/g/gauguin/02/6pould05.jpg 92 x 73 cm
314 Paul Gauguin Two Tahitian Women https://www.wga.hu/art/g/gauguin/06/tahiti72.jpg 94 x 72 cm
315 Paul Gauguin Vision After the Sermon https://www.wga.hu/art/g/gauguin/02/3ponta04.jpg 73 x 92 cm
316 Paul Gauguin Where Do We Come From https://www.wga.hu/art/g/gauguin/06/tahiti66.jpg 141 x 346 cm
317 Paul Gauguin Woman with a Hat https://www.wga.hu/art/g/gauguin/09/graphi16.jpg 150 x 108 mm
318 Paul Gauguin Young Girls by the Sea https://www.wga.hu/art/g/gauguin/04/tahiti23.jpg 68 x 92 cm
319 Peter Paul Rubens Assumption of Mary https://www.wga.hu/art/r/rubens/7graphic/04sketch.jpg 88 x 59 cm
320 Peter Paul Rubens Cupid Carving his Bow https://www.wga.hu/art/r/rubens/21mythol/14mythol.jpg 142 x 108 cm
321 Peter Paul Rubens Deer and Monkeys on the reverse of Judith with the Head of Holofernes https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg 120 x 111 cm
322 Peter Paul Rubens Esther before Ahasuerus https://www.wga.hu/art/r/rubens/7graphic/04sketc.jpg 33 x 32 cm
323 Peter Paul Rubens Feast of Herod https://www.wga.hu/art/r/rubens/15biblic/10feast.jpg 208 x 264 cm
324 Peter Paul Rubens Judith and the Head of Holofernes https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg 120 x 111 cm
325 Peter Paul Rubens Judith with the Head of Holofernes https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg 120 x 111 cm
326 Peter Paul Rubens Music-making Angels https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg 65 x 83 cm
327 Peter Paul Rubens St Francis of Assisi Receiving the Stigmata https://www.wga.hu/art/r/rubens/14religi/78religi.jpg 264 x 192 cm
328 Peter Paul Rubens The Education of the Virgin https://www.wga.hu/art/r/rubens/13religi/58religi.jpg 193 x 140 cm
329 Peter Paul Rubens The Honeysuckle Bower https://www.wga.hu/art/r/rubens/41portra/08artist.jpg 178 x 136,5 cm
330 Peter Paul Rubens The Martyrdom of St Dionysius https://www.wga.hu/art/r/rubens/14religi/73religi.jpg 455 x 347 cm
331 Peter Paul Rubens The Virgin Flanked by Music Making Angels https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg 65 x 83 cm
332 Peter Paul Rubens Virgin and Child Enthroned with Four Saints https://www.wga.hu/art/r/rubens/7graphic/11sketch.jpg 80 x 56 cm
333 Pierre Puvis de Chavannes The Poor Fisherman https://www.wga.hu/art/p/puvis/fisherma.jpg 156 x 193 cm
334 Pierre-Auguste Renoir Dance at Le Moulin de la Galette https://www.wga.hu/art/r/renoir/2/2renoi13.jpg 131 x 175 cm
335 Pierre-Auguste Renoir Diana https://www.wga.hu/art/r/renoir/1/1renoi06.jpg 196 x 130 cm
336 Pierre-Auguste Renoir Girl with a Watering Can https://www.wga.hu/art/r/renoir/2/2renoi19.jpg 100 x 73 cm
337 Pierre-Auguste Renoir Houses at Chatou https://www.wga.hu/art/r/renoir/2/2renoi31.jpg 81 x 100 cm
338 Pierre-Auguste Renoir Luncheon of the Boating Party https://www.wga.hu/art/r/renoir/3/3renoi11.jpg 130 x 173 cm
339 Pierre-Auguste Renoir Maternity https://www.wga.hu/art/r/renoir/3/3renoi26.jpg 82 x 65 cm
340 Pierre-Auguste Renoir The Farmers Lunch https://www.wga.hu/art/r/renoir/2/2renoi12.jpg 54 x 65 cm
341 Pierre-Auguste Renoir Two Sisters On the Terrace https://www.wga.hu/art/r/renoir/3/3renoi06.jpg 100 x 80 cm
342 Pontormo Joseph in Egypt https://www.wga.hu/art/p/pontormo/1/03josep3.jpg 96 x 109 cm
343 Pontormo Supper at Emmaus https://www.wga.hu/art/p/pontormo/3/04emmaus.jpg 230 x 173 cm
344 Raphael Angel with a Tambourine https://www.wga.hu/art/r/raphael/7drawing/1/34study.jpg 189 x 126 mm
345 Raphael Bache Madonna https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg
346 Raphael Baldassare Castiglione https://www.wga.hu/art/r/raphael/5roma/3/01castig.jpg 82 x 67 cm
347 Raphael Bindo Altoviti https://www.wga.hu/art/r/raphael/5roma/2/01altovi.jpg 60 x 44 cm
348 Raphael Cherubs of the Sistine Madonna https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg 270 x 201 cm
349 Raphael Coronation of Saint Nicholas from Tolentino https://www.wga.hu/art/r/raphael/7drawing/1/01study.jpg 409 x 265 mm
350 Raphael Half-length portrait of a young woman in profile https://www.wga.hu/art/r/raphael/7drawing/3/09drawim.jpg 254 x 160 mm
351 Raphael Interior of the Pantheon https://www.wga.hu/art/r/raphael/7drawing/3/08drawin.jpg 277 x 407 mm
352 Raphael Justice https://www.wga.hu/art/r/raphael/4stanze/1segnatu/5/1tondo4.jpg
353 Raphael La Madonna della sedia https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg 65,8 x 51,2 cm
354 Raphael Lucca Madonna https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg
355 Raphael Madonna and Child Madonna della Seggiola https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg
356 Raphael Madonna del Granduca https://www.wga.hu/art/r/raphael/2firenze/1/22grand.jpg 84 x 56 cm
357 Raphael Madonna della Loggia https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg
358 Raphael Madonna della sedia https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg 65,8 x 51,2 cm
359 Raphael Madonna della seggiola https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg
360 Raphael Prophet Isaiah https://www.wga.hu/art/r/raphael/5roma/1/08isaiah.jpg 250 x 155 cm
361 Raphael Sistine Madonna https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg 270 x 201 cm
362 Raphael St Michael and the Dragon by Cimabue https://www.wga.hu/art/r/raphael/2firenze/1/25drago2.jpg 31 x 27 cm
363 Raphael Study of a Kneeling Nude Girl for The Entombment https://www.wga.hu/art/r/raphael/7drawing/1/11study.jpg 230 x 319 mm
364 Raphael The School of Athens https://www.wga.hu/art/r/raphael/4stanze/1segnatu/1/athens.jpg
365 Raphael The Sistine Madonna https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg 270 x 201 cm
366 Raphael The Virgin and Child with the Young St John Tondo https://www.wga.hu/art/r/raphael/7drawing/3/35drawin.jpg 193 x 228 mm
367 Raphael Vision of the Trones https://www.wga.hu/art/r/raphael/4stanze/4constan/3vision.jpg
368 Rembrandt Christ and the Woman Taken in Adultery https://www.wga.hu/art/r/rembrand/13biblic/32newtes.jpg 84 x 65 cm
369 Rembrandt Lucretia https://www.wga.hu/art/r/rembrand/17histor/09histor.jpg 105 x 92,5 cm
370 Rembrandt The Anatomy Lesson of Dr Nicolaes Tulp https://www.wga.hu/art/r/rembrand/26group/01group.jpg 170 x 217 cm
371 Rembrandt The Night Watch https://www.wga.hu/art/r/rembrand/26group/05group.jpg 363 x 437 cm
372 Rembrandt The Red Trees https://www.wga.hu/art/r/rembrand/51etchin/4/212.jpg 213 x 279 mm
373 Sandro Botticelli Birth of Christ https://www.wga.hu/art/b/botticel/21/4birth.jpg 200 x 300 cm
374 Sandro Botticelli Fortitude https://www.wga.hu/art/b/botticel/1early/09fortit.jpg 167 x 87 cm
375 Sandro Botticelli Madonna and Child with an Angel https://www.wga.hu/art/b/botticel/1early/01madonn.jpg 87 x 60 cm
376 Sandro Botticelli Madonna in Glory with Seraphim https://www.wga.hu/art/b/botticel/1early/06glory.jpg 120 x 65 cm
377 Sandro Botticelli Madonna of the Rose https://www.wga.hu/art/b/botticel/21/51madonn.jpg 40 x 28 cm
378 Sandro Botticelli Madonna of the Rose Garden https://www.wga.hu/art/b/botticel/1early/07rosega.jpg 124 x 65 cm
379 Sandro Botticelli Portrait of Dante https://www.wga.hu/art/b/botticel/7portrai/14dante.jpg 54,7 x 47,5 cm
380 Sandro Botticelli Portrait of a Man with a Medal of Cosimo the Elder https://www.wga.hu/art/b/botticel/7portrai/04medal.jpg 57,5 x 44 cm
381 Sandro Botticelli Portrait of a Young Man possibly Giuliano de Medici https://www.wga.hu/art/b/botticel/7portrai/06medici.jpg 76 x 53 cm
382 Sandro Botticelli Predella of the Annunciation https://www.wga.hu/art/b/botticel/22/70cestel.jpg 150 x 156 cm
383 Sandro Botticelli Purgatory https://www.wga.hu/art/b/botticel/93dante/20purgat.jpg 320 x 470 mm
384 Sandro Botticelli St John on Patmos https://www.wga.hu/art/b/botticel/8smarco/21predel.jpg 21 x 269 cm
385 Sandro Botticelli The Birth of Venus https://www.wga.hu/art/b/botticel/5allegor/29birth.jpg 173 x 279 cm
386 Sandro Botticelli The Discovery of the Body of Holofernes https://www.wga.hu/art/b/botticel/1early/13holofe.jpg 31 x 25 cm
387 Sandro Botticelli The Return of Judith to Bethulia https://www.wga.hu/art/b/botticel/1early/12judith.jpg 31 x 24 cm
388 Sandro Botticelli Three Scenes from the Story of Esther https://www.wga.hu/art/b/botticel/1early/14esther.jpg 48 x 132 cm
389 Simone Martini Annunciation https://www.wga.hu/art/s/simone/6annunci/a_angel.jpg 31 x 22cm
390 Simone Martini Annunciation to Mary https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg 184 x 210 cm
391 Simone Martini Annunciation to Saint Anne https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg 184 x 210 cm
392 Simone Martini Blessing Christ https://www.wga.hu/art/s/simone/4altars/1louis/5naples.jpg 76 x 46 cm
393 Simone Martini Saint Andrew https://www.wga.hu/art/s/simone/4altars/6other/4andrew.jpg 57 x 38 cm
394 Simone Martini Saint Ansanus https://www.wga.hu/art/s/simone/4altars/6other/1ansanus.jpg 58 x 38 cm
395 Théodore Géricault The Raft of the Medusa https://www.wga.hu/art/g/gericaul/1/105geric.jpg 491 x 716 cm
396 Titian Christ the Redeemer https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg 77 x 57 cm
397 Titian Orpheus and Eurydice https://www.wga.hu/art/t/tiziano/08/03orpheu.jpg 39 x 53 cm
398 Titian Portrait of Isabella of Portugal https://www.wga.hu/art/t/tiziano/10/1/13isabel.jpg 117 x 93 cm
399 Titian The Birth of Adonis https://www.wga.hu/art/t/tiziano/08/01birth.jpg 35 x 162 cm
400 Titian The Christ Child as the Redeemer https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg 77 x 57 cm
401 Titian The Legend of Polydoros https://www.wga.hu/art/t/tiziano/08/02polydo.jpg 35 x 162 cm
402 Titian The Venus of Urbino https://www.wga.hu/art/t/tiziano/08/08urbin.jpg 119 x 165 cm
403 Titian Venus Anadyomene https://www.wga.hu/art/t/tiziano/08/07anadyo.jpg 76 x 57 cm
404 Titian Venus of Urbino https://www.wga.hu/art/t/tiziano/08/08urbin.jpg 119 x 165 cm
405 Vincent van Gogh Bal du moulin de la Galette https://www.wga.hu/art/g/gogh_van/06/paris05.jpg 46 x 38 cm
406 Vincent van Gogh Haystacks https://www.wga.hu/art/g/gogh_van/18/2arles08.jpg 241 x 319 mm
407 Vincent van Gogh Saint Paul https://www.wga.hu/art/g/gogh_van/11/1asylu01.jpg 95 x 76 cm
408 Vincent van Gogh Self-Portrait with Bandaged Ear https://www.wga.hu/art/g/gogh_van/16/selfpo31.jpg 60 x 49 cm
409 Vincent van Gogh Sunflowers https://www.wga.hu/art/g/gogh_van/09/arles63.jpg 92 x 71 cm
410 Vincent van Gogh Two Children Are Threatened by a Nightingale https://www.wga.hu/art/g/gogh_van/15/auvers25.jpg 52 x 46 cm
411 Vincent van Gogh Wheatfield with Crows https://www.wga.hu/art/g/gogh_van/15/auvers33.jpg 50,5 x 103 cm
412 Édouard Manet A Bar at the Folies-Berg re https://www.wga.hu/art/m/manet/5/5late09.jpg 96 x 130 cm
413 Édouard Manet Luncheon on the Grass https://www.wga.hu/art/m/manet/1/5dejeun1.jpg 208 x 265 cm
414 Édouard Manet Olympia https://www.wga.hu/art/m/manet/1/4olympi1.jpg 131 x 190 cm
415 Édouard Manet The Balcony https://www.wga.hu/art/m/manet/2/2manet18.jpg 170 x 125 cm
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<title>Virtual Art Gallery</title>
</head>
<body>
<div id="root"></div>
+76 -1
View File
@@ -10,8 +10,10 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0",
"three": "^0.184.0"
},
@@ -2077,6 +2079,43 @@
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -2815,6 +2854,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
@@ -3159,7 +3225,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3337,6 +3403,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+2
View File
@@ -12,8 +12,10 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0",
"three": "^0.184.0"
},
+705 -40
View File
@@ -5,19 +5,76 @@ import type {
Artist,
ArtistDetail,
MovementGalleryDetail,
ArtistSummary,
Painting,
PaintingDetail,
ArtistNavigation,
CatalogSearchResponse,
TourSummary,
TourGalleryDetail,
} from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
const API = '/api';
let apiLocale: AppLocale = readStoredLocale();
export function setApiLocale(locale: AppLocale) {
apiLocale = locale;
}
export function getApiLocale(): AppLocale {
return apiLocale;
}
function localeParams(base?: URLSearchParams): URLSearchParams {
const params = base ?? new URLSearchParams();
if (apiLocale !== 'en') params.set('locale', apiLocale);
return params;
}
function localizedPath(path: string, params?: URLSearchParams): string {
const qs = localeParams(params).toString();
return qs ? `${path}?${qs}` : path;
}
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
export type AuthRole = 'user' | 'admin' | 'curator';
export type StaffPermission =
| 'images'
| 'checkup'
| 'curator_notes'
| 'translations'
| 'influences'
| 'tours'
| 'users';
export const ALL_STAFF_PERMISSIONS: StaffPermission[] = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
export interface AuthState {
role: AuthRole;
username?: string;
permissions?: StaffPermission[];
}
export interface StaffUser {
id: number;
username: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
is_active: boolean;
created_at: string;
last_login_at: string | null;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -38,6 +95,14 @@ export async function loginCurator(username: string, password: string): Promise<
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error(
res.status === 401
? 'Login blocked by the reverse proxy (not the gallery). Use http://localhost:5173 or fix Keenetic access.'
: `Login failed: HTTP ${res.status}`
);
}
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed: ${res.status}`);
}
@@ -52,65 +117,128 @@ export async function logoutCurator(): Promise<void> {
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
}
export function imageUrl(path: string | null | undefined): string {
if (!path) return '/placeholder-art.svg';
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
export function imageUrl(path: string | null | undefined, revision?: number | null): string {
const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
if (!revision || base.startsWith('/placeholder')) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
export function portraitUrl(
path: string | null | undefined,
revision?: number | null,
options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null }
): string {
const cacheRevision =
revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined;
return imageUrl(path, cacheRevision);
}
export function portraitThumbUrl(
artist: {
portrait_thumb_path?: string | null;
portrait_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
},
revision?: number
revision?: number | null
): string {
const path = artist.portrait_thumb_path || artist.portrait_path;
return portraitUrl(path, revision);
const cacheRevision =
revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined;
return portraitUrl(path, cacheRevision);
}
export function paintingImageRevision(
painting: {
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): number | undefined {
const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key;
if (apiRevision != null) return apiRevision;
if (sessionRevision != null) return sessionRevision;
return undefined;
}
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
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}`;
export function galleryImageUrl(
painting: {
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
return null;
}
/** Ordered hall texture URLs — thumbs only (never full originals; those can be 10MB+ each). */
export function galleryImageUrlCandidates(
painting: {
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string[] {
const revision = paintingImageRevision(painting, sessionRevision);
const urls: string[] = [];
const push = (u: string | null | undefined) => {
if (u && !urls.includes(u)) urls.push(u);
};
if (painting.thumbnail_path) push(imageUrl(painting.thumbnail_path, revision));
// If DB has no thumb path but has a full file, still prefer the on-demand thumb API
// over streaming the multi-megabyte original into WebGL.
if (painting.id != null) {
push(`/api/paintings/${painting.id}/image?size=thumb`);
}
if (!painting.thumbnail_path && painting.image_path) {
push(imageUrl(painting.image_path, revision));
}
return urls;
}
export function galleryImageUrlWithRevision(
painting: {
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
revision?: number
sessionRevision?: number | null
): string | null {
const base = galleryImageUrl(painting);
if (!base || !revision) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
return galleryImageUrl(painting, sessionRevision);
}
export function paintingImageUrl(painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
checkup_fixed?: boolean;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
export function paintingImageUrl(
painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
checkup_fixed?: boolean;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.checkup_fixed) return null;
return `/api/paintings/${painting.id}/image?size=full`;
}
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
validateDebugUploadFile(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -122,7 +250,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
const comma = result.indexOf(',');
resolve({
imageData: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || 'image/jpeg',
mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
});
};
reader.onerror = () => reject(new Error('Could not read file'));
@@ -130,6 +258,56 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
});
}
async function fileToBase64Raw(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not read file'));
return;
}
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(new Error('Could not read file'));
reader.readAsDataURL(file);
});
}
function mimeTypeFromFilename(filename: string): string | null {
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
case 'gif':
return 'image/gif';
case 'avif':
return 'image/avif';
default:
return null;
}
}
export function validateDebugUploadFile(file: File): void {
const maxBytes = 15 * 1024 * 1024;
if (file.size <= 0) {
throw new Error('Selected file is empty.');
}
if (file.size > maxBytes) {
throw new Error('Image too large (max 15 MB).');
}
const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name);
if (!file.type.startsWith('image/') && !nameOk) {
throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).');
}
}
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
...fetchCredentials,
@@ -153,15 +331,29 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/movements/${movementId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}
export interface FixArtistPortraitResult {
portraitPath: string | null;
portraitThumbPath?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}
@@ -213,38 +405,95 @@ export interface PaintingCheckupData {
}
export const api = {
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
createUser: (body: {
username: string;
password: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
}) =>
fetch(`${API}/users`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`);
return data as { user: StaffUser };
}),
updateUser: (
id: number,
body: Partial<{ role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean }>
) =>
fetch(`${API}/users/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Update failed: ${res.status}`);
return data as { user: StaffUser };
}),
resetUserPassword: (id: number, password: string) =>
fetch(`${API}/users/${id}/password`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Password reset failed: ${res.status}`);
return data as { ok: boolean };
}),
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
getCatalogBootstrap: (start?: number, end?: number) => {
const params = new URLSearchParams();
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
const qs = params.toString();
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
},
getTimeline: (start: number, end: number) =>
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
getTimeline: (start: number, end: number) => {
const params = new URLSearchParams({ start: String(start), end: String(end) });
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
},
search: (q: string, options?: { limit?: number; types?: string }) => {
const params = new URLSearchParams({ q });
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.types) params.set('types', options.types);
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
},
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<Artist[]>(`${API}/artists?${params}`);
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
},
/** Lightweight artist rows for the timeline (no biography text). */
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getMovementArtistsSummary: (id: number) =>
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
@@ -312,6 +561,20 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
updatePaintingCuratorNotes: (id: number, curatorNotes: string) =>
fetch(`${API}/paintings/${id}/curator-notes`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ curatorNotes }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ curatorNotes: string }>;
}),
getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
@@ -367,9 +630,411 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
getTranslationCoverage: (locale = 'ru') =>
fetchJson<{ locale: string; coverage: Record<string, number> }>(
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
),
getTranslationWorklist: (entityType: string, locale = 'ru') =>
fetchJson<{ items: TranslationWorklistItem[] }>(
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
),
getEntityTranslation: (entityType: string, id: number) =>
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
saveEntityTranslation: (
entityType: string,
id: number,
payload: { locale: string; fields: Record<string, string>; status?: string }
) =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save failed: ${res.status}`);
}
return res.json();
}),
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Publish failed: ${res.status}`);
}
return res.json();
}),
listInfluences: (params: {
artistId?: number;
paintingId?: number;
q?: string;
limit?: number;
offset?: number;
} = {}) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
if (params.q) qs.set('q', params.q);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
const q = qs.toString();
return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>(
`${API}/influences${q ? `?${q}` : ''}`,
);
},
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
return fetchJson<InfluenceGraph>(`${API}/influences/graph?${qs.toString()}`);
},
createInfluence: (payload: {
paintingId: number;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId?: number;
sourceArtistId?: number;
sourceMovementId?: number;
notes?: string;
source?: string;
sourceUrl?: string;
}) =>
fetch(`${API}/influences`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ id: number }>;
}),
updateInfluence: (
id: number,
payload: Partial<{
notes: string;
source: string;
sourceUrl: string;
confidence: string;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId: number;
sourceArtistId: number;
sourceMovementId: number;
}>,
) =>
fetch(`${API}/influences/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ id: number }>;
}),
deleteInfluence: (id: number) =>
fetch(`${API}/influences/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}),
parseInfluenceImport: async (file: File, sheet?: string) => {
const contentBase64 = await fileToBase64Raw(file);
return fetch(`${API}/influences/import/parse`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
sheet,
contentBase64,
}),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Parse failed: ${res.status}`);
}
return res.json() as Promise<InfluenceImportParseResult>;
});
},
previewInfluenceImport: (payload: {
rows: Record<string, string>[];
mapping: Record<string, string>;
sourceLabel?: string;
contentHash?: string;
payloadHash?: string;
}) =>
fetch(`${API}/influences/import/preview`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Preview failed: ${res.status}`);
}
return res.json() as Promise<InfluenceImportPreview>;
}),
commitInfluenceImport: (payload: {
proposals: InfluenceImportProposal[];
fileName?: string;
contentHash?: string;
payloadHash?: string;
force?: boolean;
}) =>
fetch(`${API}/influences/import/commit`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & {
code?: string;
priorImport?: InfluencePriorImport;
};
err.code = body.code;
err.priorImport = body.priorImport;
throw err;
}
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
}),
listPublishedTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
listAdminTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
getTour: (id: number) =>
fetchJson<TourGalleryDetail & { locale?: string }>(`${API}/tours/${id}`),
createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) =>
fetch(`${API}/tours`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
updateTour: (
id: number,
payload: Partial<{
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
}>,
) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
deleteTour: (id: number) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}),
saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) =>
fetch(`${API}/tours/${id}/stops`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stops }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save stops failed: ${res.status}`);
}
return res.json() as Promise<{
ok: boolean;
stopCount: number;
paintings: Painting[];
stopBodies: Record<number, string>;
}>;
}),
preloadArtistImages,
preloadMovementImages,
};
export interface TranslationWorklistItem {
entityId: number;
label: string;
publishedCount: number;
draftCount: number;
missingFields: string[];
}
export interface TranslationDetail {
entityType: string;
entityId: number;
canonical: Record<string, unknown>;
translatableFields: string[];
translations: Array<{
locale: string;
field_name: string;
value: string;
status: string;
source: string | null;
updated_at: string;
}>;
}
export interface InfluenceEdgeItem {
id: number;
paintingId: number;
paintingTitle: string;
paintingYear: number | null;
artistId: number;
artistName: string;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId: number | null;
sourceArtistId: number | null;
sourceMovementId: number | null;
sourceLabel: string | null;
notes: string | null;
source: string | null;
sourceUrl: string | null;
aspects: string | null;
quote: string | null;
confidence: string | null;
discoveredVia: string | null;
updatedAt: string | null;
}
export interface InfluenceGraph {
focus: { artistId: number; paintingId: number | null; label: string };
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>;
}
export interface InfluencePriorImport {
importedAt: string;
username: string | null;
fileName: string | null;
inserted: number | null;
contentHash: string | null;
payloadHash: string | null;
match: 'file' | 'data' | 'unknown';
}
export type { TourSummary, TourGalleryDetail };
export interface InfluenceImportParseResult {
filename: string;
format: string;
sheets: string[] | null;
sheet: string | null;
columns: string[];
rowCount: number;
sampleRows: Record<string, string>[];
rows?: Record<string, string>[];
suggestedPreset: string;
suggestedMapping: Record<string, string>;
roles: string[];
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
contentHash?: string;
payloadHash?: string;
alreadyImported?: boolean;
priorImport?: InfluencePriorImport | null;
}
export interface InfluenceImportProposal {
rowIndex: number;
direction: string;
paintingId: number;
paintingTitle: string;
artistId: number;
artistName: string;
sourceType: string;
sourcePaintingId: number | null;
sourceArtistId: number | null;
sourceMovementId: number | null;
sourceLabel: string;
token: string;
notes: string | null;
source: string | null;
sourceUrl: string | null;
confidence: string;
discoveredVia: string;
edgeKey: string;
action: 'create' | 'skip';
reason: string | null;
}
export interface InfluenceImportPreview {
proposals: InfluenceImportProposal[];
warnings: Array<{
rowIndex: number;
message: string;
token?: string;
direction?: string;
candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>;
}>;
counts: {
rows: number;
proposals: number;
willCreate: number;
willSkip: number;
errors: number;
};
contentHash?: string | null;
payloadHash?: string | null;
alreadyImported?: boolean;
priorImport?: InfluencePriorImport | null;
}
export function debugImageProxyUrl(
imageUrl: string,
context?: { searchUrl?: string; source?: string }
+1
View File
@@ -1,4 +1,5 @@
.artist-bio {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #e8d5b5;
+64 -44
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { Artist } from '../types';
import {
api,
@@ -9,13 +9,17 @@ import {
type FixArtistPortraitResult,
} from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import '../components/PaintingDetail.css';
import '../components/GalleryLoadingMarker.css';
import './ArtistBio.css';
interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
debugShowMore?: boolean;
canCheckup?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
@@ -33,6 +37,7 @@ export default function ArtistBio({
artist,
debugMode = false,
debugShowMore = false,
canCheckup = true,
portraitRevision = 0,
onBack,
onEnterGallery,
@@ -51,11 +56,13 @@ export default function ArtistBio({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
const portraitSrc = uploading
? null
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
const lifespan =
artist.birth_year && artist.death_year
@@ -71,6 +78,9 @@ export default function ArtistBio({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -91,7 +101,7 @@ export default function ArtistBio({
return () => {
cancelled = true;
};
}, [debugMode, artist.id, artist.name]);
}, [debugMode, uploading, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) {
@@ -197,20 +207,27 @@ export default function ArtistBio({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally {
setUploading(false);
@@ -219,6 +236,13 @@ export default function ArtistBio({
return (
<div className="artist-bio">
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="bio-header">
<button className="back-btn" onClick={onBack}> Back</button>
<h1>{artist.name}</h1>
@@ -227,9 +251,9 @@ export default function ArtistBio({
<div className="bio-content">
<div
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
>
{showPortrait ? (
{showPortrait && portraitSrc ? (
<img
src={portraitSrc}
alt={artist.name}
@@ -273,14 +297,17 @@ export default function ArtistBio({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug portrait search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug portrait search"
>
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -293,23 +320,25 @@ export default function ArtistBio({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -317,7 +346,7 @@ export default function ArtistBio({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -331,27 +360,18 @@ export default function ArtistBio({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
</aside>
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose portrait"
data={moreResults}
loading={moreLoading}
+229
View File
@@ -0,0 +1,229 @@
.artist-filter-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.artist-filter-modal {
width: min(100%, 720px);
max-height: min(90vh, 640px);
display: flex;
flex-direction: column;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.artist-filter-header {
padding: 20px 24px 12px;
flex-shrink: 0;
}
.artist-filter-header h2 {
margin: 0 0 6px;
font-family: 'Georgia', serif;
font-size: 22px;
color: #e8d5b5;
}
.artist-filter-subtitle {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.7);
}
.artist-filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 24px 12px;
flex-shrink: 0;
}
.artist-filter-toggle-all {
border: none;
background: transparent;
color: rgba(255, 220, 160, 0.9);
font-size: 13px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.artist-filter-toggle-all:hover {
color: #ffe8c0;
}
.artist-filter-count {
font-size: 12px;
color: rgba(201, 169, 110, 0.65);
}
.artist-filter-grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 4px 24px 16px;
}
.artist-filter-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s, opacity 0.15s;
}
.artist-filter-card:hover {
border-color: rgba(255, 220, 160, 0.45);
background: rgba(0, 0, 0, 0.35);
}
.artist-filter-card-selected {
border-color: rgba(255, 220, 160, 0.55);
background: rgba(255, 220, 160, 0.08);
}
.artist-filter-card:not(.artist-filter-card-selected) {
opacity: 0.55;
}
.artist-filter-portrait-wrap {
position: relative;
width: 56px;
height: 56px;
margin-bottom: 4px;
}
.artist-filter-portrait {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(201, 169, 110, 0.4);
}
.artist-filter-portrait-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: rgba(201, 169, 110, 0.15);
color: rgba(232, 213, 181, 0.6);
font-size: 20px;
font-family: 'Georgia', serif;
}
.artist-filter-check {
position: absolute;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 220, 160, 0.95);
color: #1a1a2e;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.artist-filter-card:not(.artist-filter-card-selected) .artist-filter-check {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(201, 169, 110, 0.35);
color: transparent;
}
.artist-filter-name {
font-family: 'Georgia', serif;
font-size: 13px;
font-weight: 600;
color: #e8d5b5;
line-height: 1.25;
}
.artist-filter-years {
font-size: 11px;
color: rgba(201, 169, 110, 0.75);
}
.artist-filter-paintings {
font-size: 11px;
color: rgba(201, 169, 110, 0.55);
}
.artist-filter-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px 20px;
border-top: 1px solid rgba(201, 169, 110, 0.15);
flex-shrink: 0;
}
.artist-filter-cancel,
.artist-filter-proceed {
padding: 10px 18px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.artist-filter-cancel {
border: 1px solid rgba(201, 169, 110, 0.35);
background: transparent;
color: rgba(232, 213, 181, 0.85);
}
.artist-filter-cancel:hover {
background: rgba(201, 169, 110, 0.1);
}
.artist-filter-proceed {
border: none;
background: linear-gradient(180deg, #c9a96e 0%, #a88b4a 100%);
color: #1a1a2e;
font-weight: 600;
}
.artist-filter-proceed:hover:not(:disabled) {
filter: brightness(1.08);
}
.artist-filter-proceed:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 480px) {
.artist-filter-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding-left: 16px;
padding-right: 16px;
}
.artist-filter-header,
.artist-filter-toolbar,
.artist-filter-footer {
padding-left: 16px;
padding-right: 16px;
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from 'react';
import type { ArtistSummary } from '../types';
import { portraitThumbUrl, portraitUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import './ArtistFilterModal.css';
interface Props {
open: boolean;
movementName: string;
artists: ArtistSummary[];
portraitRevisions?: Record<number, number>;
onProceed: (selectedIds: Set<number>) => void;
onClose: () => void;
}
function formatLifespan(birth: number | null, death: number | null): string {
const b = birth != null ? String(birth) : '?';
const d = death != null ? String(death) : '?';
return `${b}${d}`;
}
function paintingCountLabel(count: number): string {
return count === 1 ? '1 painting' : `${count} paintings`;
}
function ArtistFilterPortrait({
artist,
revision,
}: {
artist: ArtistSummary;
revision?: number;
}) {
const thumbSrc =
artist.portrait_thumb_path || artist.portrait_path
? portraitThumbUrl(artist, revision)
: null;
const fullSrc = artist.portrait_path
? portraitUrl(artist.portrait_path, revision ?? artist.portrait_cache_key)
: null;
const [srcIndex, setSrcIndex] = useState(0);
const candidates = [thumbSrc, fullSrc].filter((s, i, arr): s is string => Boolean(s) && arr.indexOf(s) === i);
const requested = candidates[srcIndex] ?? null;
const queuedSrc = useQueuedImageSrc(requested);
useEffect(() => {
setSrcIndex(0);
}, [thumbSrc, fullSrc]);
if (!queuedSrc) {
return (
<span className="artist-filter-portrait artist-filter-portrait-placeholder" aria-hidden>
?
</span>
);
}
return (
<img
src={queuedSrc}
alt=""
className="artist-filter-portrait"
onError={() => {
setSrcIndex((i) => (i + 1 < candidates.length ? i + 1 : candidates.length));
}}
/>
);
}
export default function ArtistFilterModal({
open,
movementName,
artists,
portraitRevisions,
onProceed,
onClose,
}: Props) {
const allIds = useMemo(() => new Set(artists.map((a) => a.id)), [artists]);
const [selected, setSelected] = useState<Set<number>>(() => new Set(allIds));
useEffect(() => {
if (open) {
setSelected(new Set(artists.map((a) => a.id)));
}
}, [open, artists]);
if (!open) return null;
const allSelected = selected.size === artists.length && artists.length > 0;
const noneSelected = selected.size === 0;
const toggleArtist = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected(allSelected ? new Set() : new Set(allIds));
};
return (
<div className="artist-filter-backdrop" onMouseDown={onClose}>
<div
className="artist-filter-modal"
role="dialog"
aria-labelledby="artist-filter-title"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<header className="artist-filter-header">
<h2 id="artist-filter-title">{movementName}</h2>
<p className="artist-filter-subtitle">Choose artists to include in the gallery hall</p>
</header>
<div className="artist-filter-toolbar">
<button type="button" className="artist-filter-toggle-all" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="artist-filter-count">
{selected.size} of {artists.length} selected
</span>
</div>
<div className="artist-filter-grid">
{artists.map((artist) => {
const isSelected = selected.has(artist.id);
return (
<button
key={artist.id}
type="button"
className={`artist-filter-card${isSelected ? ' artist-filter-card-selected' : ''}`}
onClick={() => toggleArtist(artist.id)}
aria-pressed={isSelected}
>
<span className="artist-filter-portrait-wrap">
<ArtistFilterPortrait
artist={artist}
revision={portraitRevisions?.[artist.id]}
/>
<span className="artist-filter-check" aria-hidden>
{isSelected ? '✓' : ''}
</span>
</span>
<span className="artist-filter-name">{artist.name}</span>
<span className="artist-filter-years">{formatLifespan(artist.birth_year, artist.death_year)}</span>
<span className="artist-filter-paintings">{paintingCountLabel(artist.painting_count)}</span>
</button>
);
})}
</div>
<footer className="artist-filter-footer">
<button type="button" className="artist-filter-cancel" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="artist-filter-proceed"
disabled={noneSelected}
onClick={() => onProceed(new Set(selected))}
>
Enter gallery
</button>
</footer>
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
.catalog-search {
position: relative;
z-index: 1;
width: min(520px, 100%);
margin: 16px auto 0;
}
.catalog-search-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.catalog-search-field {
position: relative;
}
.catalog-search-input {
width: 100%;
padding: 10px 40px 10px 14px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.45);
background: rgba(15, 15, 26, 0.92);
color: #e8d5b5;
font-size: 15px;
font-family: Georgia, serif;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.catalog-search-input::placeholder {
color: rgba(232, 213, 181, 0.45);
}
.catalog-search-input:focus {
border-color: #c9a96e;
box-shadow: 0 0 0 2px rgba(201, 169, 110, 0.18);
}
.catalog-search-spinner {
position: absolute;
right: 12px;
top: 50%;
width: 16px;
height: 16px;
margin-top: -8px;
border-radius: 50%;
border: 2px solid rgba(201, 169, 110, 0.22);
border-top-color: #c9a96e;
animation: catalog-search-spin 0.85s linear infinite;
}
.catalog-search-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
max-height: min(420px, 60vh);
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(12, 12, 22, 0.98);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.catalog-search-message {
margin: 0;
padding: 14px 16px;
color: rgba(232, 213, 181, 0.75);
font-size: 14px;
}
.catalog-search-error {
color: #e8a0a0;
}
.catalog-search-group + .catalog-search-group {
border-top: 1px solid rgba(201, 169, 110, 0.12);
}
.catalog-search-group-label {
margin: 0;
padding: 10px 14px 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(201, 169, 110, 0.65);
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.catalog-search-list {
list-style: none;
margin: 0;
padding: 0 6px 8px;
}
.catalog-search-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #e8d5b5;
text-align: left;
cursor: pointer;
font: inherit;
}
.catalog-search-option:hover,
.catalog-search-option-active {
background: rgba(201, 169, 110, 0.12);
}
.catalog-search-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.04);
}
.catalog-search-movement-swatch {
width: 40px;
height: 40px;
border-radius: 4px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.catalog-search-option-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.catalog-search-option-title {
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.catalog-search-option-meta {
font-size: 12px;
color: rgba(232, 213, 181, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes catalog-search-spin {
to {
transform: rotate(360deg);
}
}
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CatalogSearchResult } from '../types';
import { api, imageUrl, portraitThumbUrl } from '../api/client';
import './CatalogSearchBar.css';
interface Props {
onSelectArtist: (artistId: number) => void;
onSelectMovement: (movementId: number) => void;
onSelectPainting: (paintingId: number) => void;
}
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
function resultKey(item: CatalogSearchResult): string {
return `${item.type}-${item.id}`;
}
function paintingThumbSrc(item: Extract<CatalogSearchResult, { type: 'painting' }>): string {
const path = item.thumbnail_path || item.image_path;
return path ? imageUrl(path) : '/placeholder-art.svg';
}
export default function CatalogSearchBar({
onSelectArtist,
onSelectMovement,
onSelectPainting,
}: Props) {
const { t } = useTranslation('search');
const typeLabels: Record<CatalogSearchResult['type'], string> = {
artist: t('groupArtist'),
movement: t('groupMovement'),
painting: t('groupPainting'),
};
const listboxId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<CatalogSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const grouped = useMemo(() => {
const map = new Map<CatalogSearchResult['type'], CatalogSearchResult[]>();
for (const type of TYPE_ORDER) map.set(type, []);
for (const item of results) {
map.get(item.type)?.push(item);
}
return TYPE_ORDER.map((type) => ({ type, items: map.get(type) ?? [] })).filter((g) => g.items.length > 0);
}, [results]);
const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
const activate = useCallback(
(item: CatalogSearchResult) => {
setOpen(false);
setQuery('');
setResults([]);
setActiveIndex(-1);
inputRef.current?.blur();
if (item.type === 'artist') onSelectArtist(item.id);
else if (item.type === 'movement') onSelectMovement(item.id);
else onSelectPainting(item.id);
},
[onSelectArtist, onSelectMovement, onSelectPainting]
);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setResults([]);
setLoading(false);
setError(null);
setActiveIndex(-1);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
api
.search(trimmed)
.then((data) => {
if (cancelled) return;
setResults(data.results);
setOpen(true);
setActiveIndex(data.results.length > 0 ? 0 : -1);
})
.catch(() => {
if (cancelled) return;
setResults([]);
setError(t('searchFailed'));
setOpen(true);
setActiveIndex(-1);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [query]);
useEffect(() => {
const onPointerDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
return;
}
if (!open || flatResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % flatResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i <= 0 ? flatResults.length - 1 : i - 1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
activate(flatResults[activeIndex]);
}
};
const showPanel = open && query.trim().length >= 2;
return (
<div className="catalog-search" ref={rootRef}>
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
{t('label')}
</label>
<div className="catalog-search-field">
<input
ref={inputRef}
id={`${listboxId}-input`}
className="catalog-search-input"
type="search"
role="combobox"
aria-expanded={showPanel}
aria-controls={showPanel ? `${listboxId}-listbox` : undefined}
aria-autocomplete="list"
aria-activedescendant={
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
}
placeholder={t('placeholder')}
value={query}
autoComplete="off"
spellCheck={false}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => {
if (query.trim().length >= 2) setOpen(true);
}}
onKeyDown={handleKeyDown}
/>
{loading && <span className="catalog-search-spinner" aria-hidden />}
</div>
{showPanel && (
<div
id={`${listboxId}-listbox`}
className="catalog-search-panel"
role="listbox"
aria-label={t('resultsLabel')}
>
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
{!error && !loading && flatResults.length === 0 && (
<p className="catalog-search-message">{t('noMatches')}</p>
)}
{grouped.map((group) => (
<div key={group.type} className="catalog-search-group">
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
<ul className="catalog-search-list">
{group.items.map((item) => {
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
const active = flatIndex === activeIndex;
return (
<li key={resultKey(item)}>
<button
type="button"
id={`${listboxId}-opt-${flatIndex}`}
role="option"
aria-selected={active}
className={`catalog-search-option${active ? ' catalog-search-option-active' : ''}`}
onMouseEnter={() => setActiveIndex(flatIndex)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => activate(item)}
>
{item.type === 'artist' && (
<>
<img
className="catalog-search-thumb"
src={portraitThumbUrl(item)}
alt=""
loading="lazy"
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.name}</span>
<span className="catalog-search-option-meta">
{[item.movement_name, formatYears(item.birth_year, item.death_year)]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
{item.type === 'movement' && (
<>
<span
className="catalog-search-movement-swatch"
style={{ background: item.color || '#c9a96e' }}
aria-hidden
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.name}</span>
<span className="catalog-search-option-meta">
{[item.era_name, formatYears(item.start_year, item.end_year)]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
{item.type === 'painting' && (
<>
<img
className="catalog-search-thumb"
src={paintingThumbSrc(item)}
alt=""
loading="lazy"
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.title}</span>
<span className="catalog-search-option-meta">
{[item.artist_name, item.year != null ? String(item.year) : null, item.movement_name]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</div>
);
}
function formatYears(start: number | null | undefined, end: number | null | undefined): string | null {
if (start == null && end == null) return null;
if (start != null && end != null) return `${start}${end}`;
if (start != null) return String(start);
return String(end);
}
+9 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import './CuratorLoginModal.css';
interface Props {
@@ -8,6 +9,8 @@ interface Props {
}
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
const { t } = useTranslation('debug');
const { t: tc } = useTranslation('common');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -32,7 +35,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
await onLogin(username.trim(), password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : t('loginFailed'));
} finally {
setSubmitting(false);
}
@@ -47,13 +50,13 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h2 id="curator-login-title">Curator login</h2>
<h2 id="curator-login-title">{t('loginTitle')}</h2>
<p className="curator-login-hint">
Debug tools and catalog edits require a curator account.
</p>
<form onSubmit={handleSubmit}>
<label className="curator-login-field">
<span>Username</span>
<span>{t('username')}</span>
<input
type="text"
autoComplete="username"
@@ -64,7 +67,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
/>
</label>
<label className="curator-login-field">
<span>Password</span>
<span>{t('password')}</span>
<input
type="password"
autoComplete="current-password"
@@ -77,10 +80,10 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
{error && <p className="curator-login-error">{error}</p>}
<div className="curator-login-actions">
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
Cancel
{tc('cancel')}
</button>
<button type="submit" className="curator-login-submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
{submitting ? t('saving') : t('signIn')}
</button>
</div>
</form>
@@ -0,0 +1,57 @@
import { useRef, type ChangeEvent } from 'react';
interface Props {
uploading: boolean;
disabled?: boolean;
onUploadPress?: () => void;
onFileSelected: (file: File) => void | Promise<void>;
}
export default function DebugUploadButton({
uploading,
disabled = false,
onUploadPress,
onFileSelected,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const inactive = disabled || uploading;
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const file = input.files?.[0];
if (!file || inactive) return;
try {
await onFileSelected(file);
} finally {
input.value = '';
}
};
return (
<label
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
aria-busy={uploading}
onClick={() => {
if (!inactive) onUploadPress?.();
}}
>
{uploading ? (
<span className="debug-upload-btn-loading">
<span className="debug-upload-btn-spinner" aria-hidden />
Uploading
</span>
) : (
'Upload'
)}
<input
ref={inputRef}
className="debug-upload-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
disabled={inactive}
onChange={handleChange}
/>
</label>
);
}
@@ -54,6 +54,22 @@
border-width: 2px;
}
.gallery-loading-marker-compact {
flex-direction: row;
justify-content: flex-start;
gap: 8px;
margin: 6px 0 8px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
}
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
width: 14px;
height: 14px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes gallery-loading-spin {
to {
transform: rotate(360deg);
@@ -6,6 +6,8 @@ interface Props {
overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */
banner?: boolean;
/** Inline row for small panels (debug upload, etc.). */
compact?: boolean;
className?: string;
}
@@ -13,13 +15,16 @@ export default function GalleryLoadingMarker({
message = 'Loading…',
overlay = false,
banner = false,
compact = false,
className = '',
}: Props) {
const modeClass = overlay
? ' gallery-loading-marker-overlay'
: banner
? ' gallery-loading-marker-banner'
: '';
: compact
? ' gallery-loading-marker-compact'
: '';
return (
<div
+10 -16
View File
@@ -173,22 +173,15 @@ function SingleWindow({
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
intensity={spec.lightIntensity * Math.PI * 0.85}
distance={14}
decay={0}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
@@ -223,8 +216,9 @@ export function GalleryTrackLights({
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -245,12 +239,12 @@ export function GalleryTrackLights({
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
angle={0.7}
penumbra={0.9}
intensity={intensity}
distance={8}
distance={12}
decay={0}
color={color}
castShadow={false}
/>
</group>
))}
+2 -2
View File
@@ -46,14 +46,14 @@ export default function HallPassage({
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]}>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
<mesh position={[0, openingH + 0.06, faceZ]}>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
+26
View File
@@ -0,0 +1,26 @@
.locale-switcher {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
overflow: hidden;
}
.locale-switcher-btn {
background: transparent;
color: inherit;
border: none;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0.75;
}
.locale-switcher-btn-active {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
.locale-switcher-btn:hover {
opacity: 1;
}
+43
View File
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { setApiLocale } from '../api/client';
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
import './LocaleSwitcher.css';
interface Props {
onLocaleChange?: (locale: AppLocale) => void;
}
export default function LocaleSwitcher({ onLocaleChange }: Props) {
const { t } = useTranslation('common');
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
const setLocale = (locale: AppLocale) => {
if (locale === current) return;
void i18n.changeLanguage(locale);
writeStoredLocale(locale);
setApiLocale(locale);
onLocaleChange?.(locale);
};
return (
<div className="locale-switcher" role="group" aria-label={t('language')}>
<button
type="button"
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('en')}
aria-pressed={current === 'en'}
>
{t('localeEn')}
</button>
<button
type="button"
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('ru')}
aria-pressed={current === 'ru'}
>
{t('localeRu')}
</button>
</div>
);
}
+49 -26
View File
@@ -28,7 +28,7 @@
.movements-flow-canvas {
--stream-stroke: 54px;
--portrait-size: 52px;
--portrait-size: 39px;
position: relative;
flex: 1;
min-height: 0;
@@ -96,12 +96,13 @@
}
.movement-branch-fast {
stroke-opacity: 0.32;
stroke-opacity: 0.55;
stroke-width: calc(var(--stream-stroke) * 0.45);
stroke-linecap: round;
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-opacity: 0.62;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
@@ -119,13 +120,14 @@
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
filter: saturate(1.25) brightness(1.12) contrast(1.08);
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
stroke-width: 2px;
stroke-linecap: round;
opacity: 0.22;
opacity: 0.38;
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
@@ -133,29 +135,30 @@
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
opacity: 0.5;
filter: saturate(1.05) brightness(0.95) contrast(1.02);
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
opacity: 0.14;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
opacity: 0.4;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
filter: brightness(1.75) saturate(1.45) contrast(1.12);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
opacity: 0.82;
filter: brightness(1.55) saturate(1.2);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
filter: brightness(1.65) saturate(1.35) contrast(1.1);
}
@keyframes stream-shimmer {
@@ -173,7 +176,29 @@
inset: 0;
}
.movements-flow-hits {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.movement-stream-hit {
position: absolute;
height: var(--stream-stroke);
transform: translateY(-50%);
pointer-events: auto;
}
.movements-flow-artists {
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 5;
}
.movements-flow-canvas.movements-flow-band-hover .movements-flow-artists {
opacity: 1;
pointer-events: auto;
}
@@ -239,23 +264,16 @@
padding: 2px 6px;
pointer-events: none;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
transform: translate(-50%, -50%);
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
background: rgba(12, 10, 18, 0.72);
border-radius: 4px;
cursor: pointer;
text-align: left;
text-align: center;
transition: background 0.2s, box-shadow 0.2s;
}
@@ -269,6 +287,11 @@
outline-offset: 2px;
}
.movement-flow-label-hover-reveal {
z-index: 8;
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.4), 0 4px 18px rgba(0, 0, 0, 0.45);
}
.movement-name {
display: block;
font-family: 'Georgia', serif;
@@ -290,8 +313,8 @@
background: none;
border: 2px solid rgba(232, 213, 181, 0.75);
border-radius: 50%;
width: var(--portrait-size, 52px);
height: var(--portrait-size, 52px);
width: var(--portrait-size, 39px);
height: var(--portrait-size, 39px);
padding: 0;
cursor: pointer;
overflow: visible;
@@ -335,7 +358,7 @@
@media (max-width: 768px) {
.movements-flow-canvas {
--stream-stroke: 44px;
--portrait-size: 44px;
--portrait-size: 33px;
}
.movements-flow-caption {
@@ -343,8 +366,8 @@
}
.artist-portrait {
width: 44px;
height: 44px;
width: var(--portrait-size, 33px);
height: var(--portrait-size, 33px);
}
.movement-flow-label {
File diff suppressed because it is too large Load Diff
+140 -22
View File
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow>
<mesh position={[0, 1.8, 0]}>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
@@ -139,50 +139,166 @@ function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged porphyry colonnettes with Byzantine basket/impost capitals at the
// corners (flush — never proud of the ~0.7m frame hang margin), a marble
// revetment dado at spring-line height, and hanging brass polycandela in
// place of wall torches (a Byzantine basilica hangs its light, not sconces it).
const colonnettes = useMemo(
() =>
[
[-halfW + 0.08, -halfD + 0.08],
[halfW - 0.08, -halfD + 0.08],
[-halfW + 0.08, halfD - 0.08],
[halfW - 0.08, halfD - 0.08],
] as [number, number][],
[halfW, halfD]
);
const lampPositions = useMemo(
() =>
[
[0, -halfD * 0.32],
[0, halfD * 0.18],
] as [number, number][],
[halfD]
);
const dadoW = Math.max(0, halfW * 2 - 0.3);
const dadoD = Math.max(0, halfD * 2 - 0.3);
return (
<group>
{columns.map(([x, z], i) => (
{/* Marble revetment dado — imperial banding course at chair-rail height */}
{[
[0, -halfD + 0.07, dadoW, 0.1] as const,
[0, halfD - 0.07, dadoW, 0.1] as const,
[-halfW + 0.07, 0, 0.1, dadoD] as const,
[halfW - 0.07, 0, 0.1, dadoD] as const,
].map(([x, z, w, d], i) => (
<group key={i}>
<mesh position={[x, 0.62, z]}>
<boxGeometry args={[w, 1.24, d]} />
<meshStandardMaterial color="#6d5644" roughness={0.34} metalness={0.12} />
</mesh>
<mesh position={[x, 1.28, z]}>
<boxGeometry args={[w * 1.004, 0.09, d * 1.004]} />
<meshStandardMaterial color="#d8c8a8" roughness={0.42} metalness={0.1} />
</mesh>
</group>
))}
{/* Engaged colonnettes, basket capital, and impost block */}
{colonnettes.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
{/* Polished porphyry shaft — the imperial stone of the reference basilicas */}
<mesh position={[0, 1.95, 0]}>
<cylinderGeometry args={[0.14, 0.16, 3.4, 14]} />
<meshStandardMaterial color="#43222a" roughness={0.32} metalness={0.16} />
</mesh>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.17, 0.19, 0.24, 14]} />
<meshStandardMaterial color="#d8cdb4" roughness={0.5} metalness={0.08} />
</mesh>
{/* Basket capital in white marble, then an impost block carrying the ceiling */}
<mesh position={[0, 3.8, 0]}>
<cylinderGeometry args={[0.22, 0.11, 0.3, 8]} />
<meshStandardMaterial color="#e8e0cc" roughness={0.44} metalness={0.06} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.28, 0.22, 0.28]} />
<meshStandardMaterial color="#ded4bc" roughness={0.5} metalness={0.05} />
</mesh>
</group>
))}
{/* Hanging polycandela — brass ring lamps suspended from the vault */}
{lampPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 4.1, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.5, 6]} />
<meshStandardMaterial color="#5a4a20" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[0, 3.84, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.32, 0.025, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.8} emissive="#3a2808" emissiveIntensity={0.1} />
</mesh>
{Array.from({ length: 6 }, (_, j) => {
const a = (j / 6) * Math.PI * 2;
return (
<mesh key={j} position={[Math.cos(a) * 0.32, 3.76, Math.sin(a) * 0.32]}>
<sphereGeometry args={[0.045, 8, 8]} />
<meshStandardMaterial color="#ffcf80" emissive="#ff9830" emissiveIntensity={0.9} toneMapped={false} />
</mesh>
);
})}
<pointLight position={[0, 3.76, 0]} intensity={0.85} distance={6} color="#ffb060" />
</group>
))}
</group>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
const pilasters = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
<boxGeometry args={[0.12, 3.7, 0.12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
{/* Cornice above wall title (title sits ~y 3.75) */}
<mesh position={[0, 4.08, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
const columns = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} />
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
@@ -210,10 +326,12 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
+12 -9
View File
@@ -1,13 +1,14 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = {
technique: 'Technique',
composition: 'Composition',
symbolism: 'Symbolism',
history: 'History',
subject: 'Subject',
const CATEGORY_KEYS: Record<string, string> = {
technique: 'categoryTechnique',
composition: 'categoryComposition',
symbolism: 'categorySymbolism',
history: 'categoryHistorical',
subject: 'categorySubject',
};
interface PanelProps {
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
activeId,
onSelect,
}: PanelProps) {
const { t } = useTranslation('annotations');
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">Art history notes</h3>
<h3 className="painting-annotations-title">{t('title')}</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const category = CATEGORY_LABELS[ann.category] || ann.category;
const categoryKey = CATEGORY_KEYS[ann.category];
const category = categoryKey ? t(categoryKey) : ann.category;
return (
<li
key={ann.id}
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source
{t('readSource')}
</a>
)}
</li>
+186 -2
View File
@@ -407,6 +407,149 @@
display: block;
}
.tour-stop-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.12);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
}
.tour-stop-panel h3 {
margin: 0 0 10px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.tour-stop-body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.7;
color: rgba(232, 213, 181, 0.92);
white-space: pre-wrap;
}
.tour-stop-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.curator-notes-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.1);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.28);
}
.curator-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.curator-notes-panel h3 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.curator-notes-body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.7;
color: rgba(232, 213, 181, 0.92);
white-space: pre-wrap;
}
.curator-notes-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.curator-notes-edit-btn,
.curator-notes-save-btn,
.curator-notes-cancel-btn {
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(201, 169, 110, 0.45);
background: transparent;
color: #c9a96e;
}
.curator-notes-edit-btn:hover,
.curator-notes-cancel-btn:hover {
background: rgba(201, 169, 110, 0.12);
}
.curator-notes-save-btn {
background: rgba(201, 169, 110, 0.2);
color: #e8d5b5;
}
.curator-notes-save-btn:hover:not(:disabled) {
background: rgba(201, 169, 110, 0.32);
}
.curator-notes-edit-btn:disabled,
.curator-notes-save-btn:disabled,
.curator-notes-cancel-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.curator-notes-textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.55);
color: rgba(232, 213, 181, 0.95);
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
.curator-notes-textarea:focus {
outline: none;
border-color: rgba(201, 169, 110, 0.7);
}
.curator-notes-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.curator-notes-error {
margin: 8px 0 0;
font-size: 13px;
color: #e08a6a;
}
.painting-description {
max-width: 700px;
margin-top: 20px;
@@ -552,6 +695,48 @@
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.debug-upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.debug-upload-btn-disabled {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.debug-upload-btn-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.debug-upload-page-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 300;
}
.painting-frame-uploading,
.bio-portrait-uploading {
min-height: 240px;
}
.debug-image-panel.debug-image-panel-blocked {
opacity: 0.55;
pointer-events: none;
}
.debug-clear-btn {
@@ -576,8 +761,7 @@
border-color: #8cbe8c;
}
.debug-clear-btn:disabled,
.debug-upload-btn:disabled {
.debug-clear-btn:disabled {
opacity: 0.6;
cursor: wait;
}
+198 -65
View File
@@ -1,19 +1,29 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
import './GalleryLoadingMarker.css';
interface Props {
data: PaintingDetail;
artistPaintings?: Painting[];
backLabel?: string;
tourTitle?: string | null;
tourText?: string | null;
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
isCurator?: boolean;
/** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */
canCheckup?: boolean;
debugMode?: boolean;
debugShowMore?: boolean;
onPaintingImageFixed?: (
@@ -25,6 +35,7 @@ interface Props {
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
}
function influenceKey(inf: InfluenceLink, index: number): string {
@@ -188,18 +199,31 @@ function InfluenceCard({
export default function PaintingDetailView({
data,
artistPaintings = [],
backLabel = '← Back to Gallery',
tourTitle = null,
tourText = null,
onBack,
onPaintingClick,
onCatalogNavigate,
onArtistBio,
onInfluenceArtistClick,
isCurator = false,
canCheckup = true,
debugMode = false,
debugShowMore = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
onCuratorNotesUpdated,
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
const inTour = tourText != null;
const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? '');
const [editingNotes, setEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? '');
const [savingNotes, setSavingNotes] = useState(false);
const [notesError, setNotesError] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -214,15 +238,13 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
const imageSrc = baseImageUrl
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
const displayImageSrc = uploading ? null : imageSrc;
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -243,10 +265,17 @@ export default function PaintingDetailView({
setFixing(false);
setClearing(false);
setUploading(false);
setUploadStatus(null);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
}, [painting.id]);
const notes = painting.curator_notes ?? '';
setCuratorNotes(notes);
setNotesDraft(notes);
setEditingNotes(false);
setSavingNotes(false);
setNotesError(null);
}, [painting.id, painting.curator_notes]);
useEffect(() => {
if (!debugMode) {
@@ -255,6 +284,9 @@ export default function PaintingDetailView({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -275,7 +307,35 @@ export default function PaintingDetailView({
return () => {
cancelled = true;
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const startEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(true);
};
const cancelEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(false);
};
const saveCuratorNotes = async () => {
setSavingNotes(true);
setNotesError(null);
try {
const result = await api.updatePaintingCuratorNotes(painting.id, notesDraft);
setCuratorNotes(result.curatorNotes);
setNotesDraft(result.curatorNotes);
setEditingNotes(false);
onCuratorNotesUpdated?.(painting.id, result.curatorNotes);
} catch {
setNotesError(t('curatorNotesSaveFailed'));
} finally {
setSavingNotes(false);
}
};
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
@@ -382,20 +442,27 @@ export default function PaintingDetailView({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally {
setUploading(false);
@@ -437,8 +504,15 @@ export default function PaintingDetailView({
return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="painting-header">
<button className="back-btn" onClick={onBack}> Back to Gallery</button>
<button className="back-btn" onClick={onBack}>{backLabel}</button>
<div className="painting-title-block">
<h1>{painting.title}</h1>
<p className="painting-meta">
@@ -447,21 +521,23 @@ export default function PaintingDetailView({
{showCatalogNav && (
<span className="painting-catalog-position">
{' · '}
{catalogIndex + 1} of {artistPaintings.length}
{inTour
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
: `${catalogIndex + 1} of ${artistPaintings.length}`}
</span>
)}
</p>
</div>
<button className="bio-btn" onClick={onArtistBio}>
About {painting.artist_name}
{t('aboutArtist', { name: painting.artist_name })}
</button>
</header>
<div className="painting-layout">
<aside className="influence-panel influence-left">
<h3>Influenced By</h3>
<h3>{t('influencedBy')}</h3>
{influencedBy.length === 0 ? (
<p className="no-influences">No documented influences for this work.</p>
<p className="no-influences">{t('noInfluences')}</p>
) : (
<div className="influence-list">
{influencedBy.map((inf, index) => (
@@ -494,12 +570,12 @@ export default function PaintingDetailView({
)}
<div
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
role={imageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined}
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
role={displayImageSrc ? 'button' : undefined}
tabIndex={displayImageSrc ? 0 : undefined}
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={
imageSrc
displayImageSrc
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -508,14 +584,14 @@ export default function PaintingDetailView({
}
: undefined
}
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
title={displayImageSrc ? 'View full screen' : undefined}
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
{displayImageSrc ? (
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
{displayImageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
@@ -546,6 +622,67 @@ export default function PaintingDetailView({
</button>
)}
</div>
{inTour && (
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
{tourText.trim() ? (
<p className="tour-stop-body">{tourText}</p>
) : (
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
)}
</aside>
)}
{(isCurator || curatorNotes.trim()) && (
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
<div className="curator-notes-header">
<h3>{t('curatorNotes')}</h3>
{isCurator && !editingNotes && (
<button
type="button"
className="curator-notes-edit-btn"
onClick={startEditingNotes}
>
{t('curatorNotesEdit')}
</button>
)}
</div>
{editingNotes ? (
<div className="curator-notes-editor">
<textarea
className="curator-notes-textarea"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
rows={6}
disabled={savingNotes}
aria-label={t('curatorNotes')}
/>
{notesError && <p className="curator-notes-error">{notesError}</p>}
<div className="curator-notes-actions">
<button
type="button"
className="curator-notes-save-btn"
onClick={saveCuratorNotes}
disabled={savingNotes}
>
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
</button>
<button
type="button"
className="curator-notes-cancel-btn"
onClick={cancelEditingNotes}
disabled={savingNotes}
>
{t('curatorNotesCancel')}
</button>
</div>
</div>
) : curatorNotes.trim() ? (
<p className="curator-notes-body">{curatorNotes}</p>
) : (
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && (
<div className="painting-description">
<p>{painting.description}</p>
@@ -554,9 +691,9 @@ export default function PaintingDetailView({
</main>
<aside className="influence-panel influence-right">
<h3>Influenced</h3>
<h3>{t('influenced')}</h3>
{influenced.length === 0 ? (
<p className="no-influences">No documented works influenced by this painting yet.</p>
<p className="no-influences">{t('noInfluencedWorks')}</p>
) : (
<div className="influence-list">
{influenced.map((inf, index) => (
@@ -573,14 +710,17 @@ export default function PaintingDetailView({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug image search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
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 && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -593,23 +733,25 @@ export default function PaintingDetailView({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -617,7 +759,7 @@ export default function PaintingDetailView({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -631,20 +773,11 @@ export default function PaintingDetailView({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
<div className="debug-action-buttons debug-action-buttons-danger">
@@ -661,7 +794,7 @@ export default function PaintingDetailView({
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose painting image"
data={moreResults}
loading={moreLoading}
@@ -671,9 +804,9 @@ export default function PaintingDetailView({
onSelect={handleSelectMoreResult}
/>
{fullscreen && imageSrc && (
{fullscreen && displayImageSrc && (
<PaintingLightbox
src={imageSrc}
src={displayImageSrc}
alt={painting.title}
title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
+144
View File
@@ -0,0 +1,144 @@
.tours-popup-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.tours-popup-modal {
width: min(100%, 520px);
max-height: min(85vh, 640px);
overflow: auto;
padding: 20px 22px 24px;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
color: #e8d5b5;
}
.tours-popup-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.tours-popup-header h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
color: #e8d5b5;
}
.tours-popup-close {
border: none;
background: transparent;
color: rgba(201, 169, 110, 0.85);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.tours-popup-hint {
margin: 0 0 14px;
font-size: 0.85rem;
color: rgba(201, 169, 110, 0.7);
line-height: 1.45;
}
.tours-popup-muted {
margin: 0;
color: rgba(201, 169, 110, 0.55);
font-size: 0.9rem;
}
.tours-popup-error {
margin: 0 0 10px;
color: #ffaaaa;
font-size: 0.9rem;
}
.tours-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.tours-popup-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.28);
background: rgba(0, 0, 0, 0.28);
color: inherit;
cursor: pointer;
}
.tours-popup-card:hover {
border-color: rgba(201, 169, 110, 0.55);
background: rgba(201, 169, 110, 0.08);
}
.tours-popup-cover {
flex: 0 0 72px;
width: 72px;
height: 72px;
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.35);
}
.tours-popup-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tours-popup-cover-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(201, 169, 110, 0.15), rgba(0, 0, 0, 0.4));
}
.tours-popup-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.tours-popup-body strong {
font-size: 0.95rem;
}
.tours-popup-body p {
margin: 0;
font-size: 0.8rem;
line-height: 1.4;
color: rgba(232, 213, 181, 0.75);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tours-popup-meta {
font-size: 0.75rem;
color: rgba(201, 169, 110, 0.65);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, imageUrl } from '../api/client';
import type { TourSummary } from '../types';
import './ToursPopup.css';
interface Props {
open: boolean;
onClose: () => void;
onSelectTour: (tourId: number) => void;
}
export default function ToursPopup({ open, onClose, onSelectTour }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
api
.listPublishedTours()
.then((data) => {
if (!cancelled) setTours(data.tours);
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('loadFailed'));
setTours([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, t]);
if (!open) return null;
return (
<div className="tours-popup-backdrop" role="presentation" onClick={onClose}>
<div
className="tours-popup-modal"
role="dialog"
aria-modal="true"
aria-labelledby="tours-popup-title"
onClick={(e) => e.stopPropagation()}
>
<header className="tours-popup-header">
<h2 id="tours-popup-title">{t('popupTitle')}</h2>
<button type="button" className="tours-popup-close" onClick={onClose} aria-label={t('close')}>
×
</button>
</header>
<p className="tours-popup-hint">{t('popupHint')}</p>
{loading && <p className="tours-popup-muted">{t('loading')}</p>}
{error && <p className="tours-popup-error">{error}</p>}
{!loading && !error && tours.length === 0 && (
<p className="tours-popup-muted">{t('noPublished')}</p>
)}
<ul className="tours-popup-list">
{tours.map((tour) => {
const cover = tour.coverThumbnailPath || tour.coverImagePath;
return (
<li key={tour.id}>
<button
type="button"
className="tours-popup-card"
onClick={() => onSelectTour(tour.id)}
>
<div className="tours-popup-cover">
{cover ? (
<img src={imageUrl(cover)} alt="" />
) : (
<span className="tours-popup-cover-empty" aria-hidden />
)}
</div>
<div className="tours-popup-body">
<strong>{tour.title}</strong>
{tour.description ? <p>{tour.description}</p> : null}
<span className="tours-popup-meta">
{t('stopCount', { count: tour.stopCount })}
</span>
</div>
</button>
</li>
);
})}
</ul>
</div>
</div>
);
}
+6
View File
@@ -419,3 +419,9 @@
width: 100%;
margin-top: 8px;
}
.exit-nav-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(201, 169, 110, 0.25);
}
File diff suppressed because it is too large Load Diff
+40 -8
View File
@@ -1,11 +1,20 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
import { useCallback, useContext, useEffect, useMemo, useState, type ReactNode, createContext } from 'react';
import {
getAuthMe,
loginCurator,
logoutCurator,
type AuthRole,
type StaffPermission,
} from '../api/client';
interface AuthContextValue {
role: AuthRole;
username?: string;
permissions: StaffPermission[];
isCurator: boolean;
isAdmin: boolean;
loading: boolean;
can: (permission: StaffPermission) => boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
@@ -13,15 +22,26 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
function applyAuthState(
me: { role: AuthRole; username?: string; permissions?: StaffPermission[] },
setRole: (r: AuthRole) => void,
setUsername: (u: string | undefined) => void,
setPermissions: (p: StaffPermission[]) => void
) {
setRole(me.role);
setUsername(me.username);
setPermissions(me.role === 'user' ? [] : me.permissions ?? []);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [role, setRole] = useState<AuthRole>('user');
const [username, setUsername] = useState<string | undefined>();
const [permissions, setPermissions] = useState<StaffPermission[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const me = await getAuthMe();
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
useEffect(() => {
@@ -30,27 +50,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (user: string, password: string) => {
const me = await loginCurator(user, password);
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
const logout = useCallback(async () => {
await logoutCurator();
setRole('user');
setUsername(undefined);
setPermissions([]);
}, []);
const can = useCallback(
(permission: StaffPermission) => {
if (role === 'admin') return true;
if (role !== 'curator') return false;
return permissions.includes(permission);
},
[role, permissions]
);
const value = useMemo(
() => ({
role,
username,
isCurator: role === 'curator',
permissions,
isCurator: role === 'admin' || role === 'curator',
isAdmin: role === 'admin',
loading,
can,
login,
logout,
refresh,
}),
[role, username, loading, login, logout, refresh]
[role, username, permissions, loading, can, login, logout, refresh]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+67 -22
View File
@@ -52,7 +52,9 @@ export interface MovementInteriorStyle {
doorWood: [string, string, string];
windows: GalleryWindowSpec[];
trackLights: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
}
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
@@ -85,6 +87,7 @@ function mk(
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
windows: opts.windows,
trackLights: opts.trackLights ?? 0.8,
lightScale: opts.lightScale ?? 1,
details: opts.details,
};
}
@@ -114,25 +117,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
28: mk(
'byzantine-sanctuary',
'Byzantine chapel',
'Gold mosaic walls · amber light through arched windows',
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
'Byzantine basilica',
'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
{ wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
{ wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
{
details: 'medieval',
titleColor: '#f0e8c0',
ambient: 0.48,
warmLight: '#ffd898',
sunLight: '#ffe8b0',
fog: '#0a0806',
background: '#060504',
details: 'byzantine',
titleColor: '#e8d8b0',
ambient: 0.44,
warmLight: '#ffdca8',
sunLight: '#ffdca0',
fog: '#100b07',
background: '#0a0705',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
{ wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
{ wall: 'left', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'left', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 }
),
trackLights: 0.5,
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
trackLights: 0.35,
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
lightScale: 0.3,
doorWood: ['#3a2416', '#4c3220', '#241608'],
}
),
29: mk(
@@ -144,17 +151,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.52,
ambient: 0.72,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
fog: '#0c0c10',
// Keep atmosphere dark, but not pure void (walls must stay readable).
fog: '#1a1816',
background: '#141210',
windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
),
trackLights: 0.3,
trackLights: 0.85,
}
),
30: mk(
@@ -578,13 +587,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
};
}
/**
* Styles were authored with legacy ids 2752; gallery_dev uses 126.
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
*/
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
1: 27,
2: 28,
3: 29,
4: 30,
5: 32,
6: 31,
7: 33,
8: 34,
9: 35,
10: 36,
11: 37,
12: 38,
13: 39,
14: 40,
15: 41,
16: 42,
17: 43,
18: 44,
19: 45,
20: 46,
21: 47,
22: 48,
23: 49,
24: 50,
25: 51,
26: 52,
};
/** Fallback name-based resolver for movements not in the catalog. */
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
const n = movement.name.toLowerCase();
const era = (movement.era_name ?? '').toLowerCase();
if (n.includes('byzantine')) return BY_MOVEMENT_ID[28];
if (n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (era.includes('medieval')) return BY_MOVEMENT_ID[29];
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
return BY_MOVEMENT_ID[36];
@@ -593,7 +637,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
export function resolveMovementInteriorStyle(
movement: ArtMovement & { era_name?: string }
): MovementInteriorStyle {
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
return blendAccent(base, movement.color);
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65,
// Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
});
}, [kind, tint, spanW, spanH]);
+73
View File
@@ -0,0 +1,73 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { readStoredLocale, writeStoredLocale } from '../utils/localeStorage';
import enCommon from '../locales/en/common.json';
import enHome from '../locales/en/home.json';
import enSearch from '../locales/en/search.json';
import enGallery from '../locales/en/gallery.json';
import enPainting from '../locales/en/painting.json';
import enBio from '../locales/en/bio.json';
import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
import ruSearch from '../locales/ru/search.json';
import ruGallery from '../locales/ru/gallery.json';
import ruPainting from '../locales/ru/painting.json';
import ruBio from '../locales/ru/bio.json';
import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
defaultNS: 'common',
resources: {
en: {
common: enCommon,
home: enHome,
search: enSearch,
gallery: enGallery,
painting: enPainting,
bio: enBio,
annotations: enAnnotations,
debug: enDebug,
translations: enTranslations,
influences: enInfluences,
tours: enTours,
users: enUsers,
},
ru: {
common: ruCommon,
home: ruHome,
search: ruSearch,
gallery: ruGallery,
painting: ruPainting,
bio: ruBio,
annotations: ruAnnotations,
debug: ruDebug,
translations: ruTranslations,
influences: ruInfluences,
tours: ruTours,
users: ruUsers,
},
},
interpolation: { escapeValue: false },
});
export default i18n;
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Art history notes",
"readSource": "Read source",
"categoryTechnique": "Technique",
"categoryComposition": "Composition",
"categorySubject": "Subject",
"categorySymbolism": "Symbolism",
"categoryHistorical": "Historical context",
"categoryOther": "Notes"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Enter Gallery",
"noBio": "No biography available yet.",
"wikipediaSource": "Text adapted from Wikipedia",
"backToTimeline": "← Back to Timeline"
}
+13
View File
@@ -0,0 +1,13 @@
{
"loading": "Loading…",
"close": "Close",
"back": "Back",
"save": "Save",
"cancel": "Cancel",
"yes": "Yes",
"no": "No",
"error": "Something went wrong",
"localeEn": "EN",
"localeRu": "RU",
"language": "Language"
}
+14
View File
@@ -0,0 +1,14 @@
{
"checked": "Checked",
"fixIt": "Fix it",
"more": "More",
"clear": "Clear",
"upload": "Upload",
"searching": "Searching…",
"saving": "Saving…",
"loginTitle": "Curator login",
"username": "Username",
"password": "Password",
"signIn": "Sign in",
"loginFailed": "Login failed"
}
+14
View File
@@ -0,0 +1,14 @@
{
"backToTimeline": "← Back to Timeline",
"exitToTimeline": "Exit to Timeline",
"loadingGallery": "Loading gallery…",
"instructionsTitle": "Gallery controls",
"instructionMove": "Drag to look around",
"instructionZoom": "Scroll to zoom",
"instructionClick": "Click a painting to view details",
"wingOf": "Wing {{current}} of {{total}}",
"exitConfirmTitle": "Leave gallery?",
"exitConfirmBody": "Return to the timeline or stay in the hall.",
"stay": "Stay",
"exit": "Exit"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Virtual Art Gallery",
"subtitle": "Explore art history on an interactive timeline — enter 3D galleries and discover connections between artists and masterpieces.",
"loadingArtHistory": "Loading art history…",
"loadingPortraits": "Loading portraits…",
"openingArtistGallery": "Opening artist gallery…",
"openingMovementGallery": "Opening movement gallery…",
"backToTimeline": "← Back to Timeline",
"backToGallery": "← Back to Gallery",
"curatorLogin": "Curator login",
"curatorLogout": "Log out",
"signedInAs": "Signed in as {{username}}",
"debugMode": "Debug mode",
"showMoreDebug": "Show more (debug)",
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
"curatorRequiredTitle": "Curator access required",
"curatorRequiredBody": "Sign in as a curator to use this tool.",
"backToGalleryBtn": "Back to gallery"
}
+70
View File
@@ -0,0 +1,70 @@
{
"title": "Influence links",
"back": "← Back to gallery",
"tabList": "List",
"tabImport": "Import",
"tabGraph": "Graph",
"loadFailed": "Failed to load influences",
"searchPlaceholder": "Search artist, painting, source…",
"refresh": "Refresh",
"loading": "Loading…",
"loadingParse": "Reading file…",
"loadingPreview": "Validating influence links…",
"loadingCommit": "Importing links into the database…",
"loadingSave": "Saving link…",
"addEdge": "Add link",
"cancelAdd": "Cancel",
"total": "{{count}} links",
"filtered": "filtered by artist",
"clearFilter": "Clear artist filter",
"subjectPainting": "Subject painting",
"searchPainting": "Search painting…",
"sourceType": "Source type",
"typeArtist": "Artist",
"typePainting": "Painting",
"typeMovement": "Movement",
"sourceEntity": "Source entity",
"searchSource": "Search source…",
"notes": "Notes",
"saveEdge": "Save link",
"addRequiresIds": "Pick a subject painting and a source entity",
"colSubject": "Subject",
"colSource": "Source",
"colType": "Type",
"colNotes": "Notes",
"colActions": "Actions",
"colAction": "Action",
"colDirection": "Direction",
"delete": "Delete",
"confirmDelete": "Delete this influence link?",
"noEdges": "No influence links match.",
"stepUpload": "1. Upload",
"stepMapping": "2. Map columns",
"stepPreview": "3. Validate",
"stepDone": "4. Done",
"uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).",
"parseFailed": "Failed to parse file",
"previewFailed": "Failed to build preview",
"commitFailed": "Failed to commit import",
"rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).",
"fileInfo": "{{name}} · {{rows}} rows · {{format}}",
"sheet": "Sheet",
"preset": "Mapping preset",
"column": "Column",
"role": "Role",
"sample": "Sample",
"backStep": "Back",
"runPreview": "Validate & preview",
"previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}",
"warnings": "{{count}} warnings",
"commitImport": "Import {{count}} links",
"commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).",
"alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.",
"alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.",
"forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)",
"matchFile": "same file bytes",
"matchData": "same mapped data",
"searchArtist": "Search artist for neighborhood graph…",
"graphEmpty": "Select an artist to visualize influence neighbors.",
"graphStats": "{{nodes}} nodes · {{edges}} edges"
}
+22
View File
@@ -0,0 +1,22 @@
{
"aboutArtist": "About {{name}}",
"influencedBy": "Influenced By",
"influenced": "Influenced",
"noInfluences": "No documented influences for this work.",
"noInfluencedWorks": "No documented works influenced by this painting yet.",
"catalogPosition": "Catalog position",
"lightboxHint": "Click anywhere to close",
"prevPainting": "Previous painting",
"nextPainting": "Next painting",
"tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}",
"curatorNotes": "Curator notes",
"curatorNotesEmpty": "No curator notes yet.",
"curatorNotesEdit": "Edit",
"curatorNotesSave": "Save",
"curatorNotesSaving": "Saving…",
"curatorNotesCancel": "Cancel",
"curatorNotesSaveFailed": "Could not save curator notes."
}
+10
View File
@@ -0,0 +1,10 @@
{
"label": "Search",
"placeholder": "Search artists, paintings, movements…",
"resultsLabel": "Search results",
"noMatches": "No matches found.",
"groupArtist": "Artists",
"groupMovement": "Movements",
"groupPainting": "Paintings",
"searchFailed": "Search failed"
}
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Guided tours",
"popupTitle": "Guided tours",
"popupHint": "Choose a curated walkthrough of selected works.",
"close": "Close",
"loading": "Loading…",
"loadFailed": "Failed to load tours",
"noPublished": "No published tours yet.",
"noTours": "No tours yet. Create one to get started.",
"stopCount": "{{count}} stops",
"back": "← Back to gallery",
"create": "Create",
"newTourPlaceholder": "New tour title…",
"selectTour": "Select a tour to edit.",
"tourTitle": "Title",
"tourDescription": "Description",
"status": "Status",
"draft": "Draft",
"published": "Published",
"saveMeta": "Save details",
"delete": "Delete tour",
"confirmDelete": "Delete this tour and all its stops?",
"searchPainting": "Search paintings to add…",
"saveStops": "Save stops",
"remove": "Remove",
"stopBodyPlaceholder": "Tour notes for this stop (English)…",
"noStops": "No stops yet. Search and add paintings."
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Translation review",
"back": "← Back to gallery",
"locale": "Locale",
"entityType": "Entity type",
"status": "Status",
"all": "All",
"draft": "Draft",
"reviewed": "Reviewed",
"published": "Published",
"coverage": "Coverage",
"artistsBio": "Artists with bio (ru)",
"paintingsTitle": "Paintings with title alias (ru)",
"canonical": "English (canonical)",
"translation": "Translation",
"saveDraft": "Save draft",
"publish": "Publish",
"noRows": "No translation rows match the filter.",
"loadFailed": "Failed to load translations"
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Users",
"back": "← Back",
"loadFailed": "Failed to load users",
"createTitle": "Create user",
"username": "Username",
"password": "Password",
"role": "Role",
"roleAdmin": "Admin",
"roleCurator": "Curator",
"permissions": "Permissions",
"active": "Active",
"inactive": "Disabled",
"create": "Create",
"creating": "Creating…",
"save": "Save",
"saving": "Saving…",
"resetPassword": "Reset password",
"newPassword": "New password",
"lastLogin": "Last login",
"never": "Never",
"selectUser": "Select a user to edit",
"loading": "Loading…",
"perm_images": "Images (fix/upload/debug)",
"perm_checkup": "Checkup",
"perm_curator_notes": "Curator notes",
"perm_translations": "Translations",
"perm_influences": "Influences",
"perm_tours": "Tours",
"perm_users": "Users",
"adminAllPerms": "Admins have all permissions automatically.",
"deactivate": "Disable account",
"activate": "Enable account",
"created": "User created",
"saved": "Saved",
"passwordReset": "Password updated"
}
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Историко-художественные заметки",
"readSource": "Источник",
"categoryTechnique": "Техника",
"categoryComposition": "Композиция",
"categorySubject": "Сюжет",
"categorySymbolism": "Символика",
"categoryHistorical": "Исторический контекст",
"categoryOther": "Заметки"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Войти в галерею",
"noBio": "Биография пока недоступна.",
"wikipediaSource": "Текст адаптирован из Википедии",
"backToTimeline": "← На шкалу времени"
}
+13
View File
@@ -0,0 +1,13 @@
{
"loading": "Загрузка…",
"close": "Закрыть",
"back": "Назад",
"save": "Сохранить",
"cancel": "Отмена",
"yes": "Да",
"no": "Нет",
"error": "Что-то пошло не так",
"localeEn": "EN",
"localeRu": "RU",
"language": "Язык"
}
+14
View File
@@ -0,0 +1,14 @@
{
"checked": "Проверено",
"fixIt": "Исправить",
"more": "Ещё",
"clear": "Очистить",
"upload": "Загрузить",
"searching": "Поиск…",
"saving": "Сохранение…",
"loginTitle": "Вход куратора",
"username": "Имя пользователя",
"password": "Пароль",
"signIn": "Войти",
"loginFailed": "Ошибка входа"
}
+14
View File
@@ -0,0 +1,14 @@
{
"backToTimeline": "← На шкалу времени",
"exitToTimeline": "Выход на шкалу времени",
"loadingGallery": "Загрузка галереи…",
"instructionsTitle": "Управление",
"instructionMove": "Перетаскивайте для обзора",
"instructionZoom": "Колёсико — масштаб",
"instructionClick": "Нажмите на картину для подробностей",
"wingOf": "Крыло {{current}} из {{total}}",
"exitConfirmTitle": "Покинуть галерею?",
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
"stay": "Остаться",
"exit": "Выход"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Виртуальная художественная галерея",
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
"loadingArtHistory": "Загрузка истории искусства…",
"loadingPortraits": "Загрузка портретов…",
"openingArtistGallery": "Открытие галереи художника…",
"openingMovementGallery": "Открытие галереи направления…",
"backToTimeline": "← На шкалу времени",
"backToGallery": "← В галерею",
"curatorLogin": "Вход куратора",
"curatorLogout": "Выйти",
"signedInAs": "Вы вошли как {{username}}",
"debugMode": "Режим отладки",
"showMoreDebug": "Показать больше (отладка)",
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
"curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею"
}
+70
View File
@@ -0,0 +1,70 @@
{
"title": "Связи влияния",
"back": "← Назад в галерею",
"tabList": "Список",
"tabImport": "Импорт",
"tabGraph": "Граф",
"loadFailed": "Не удалось загрузить связи",
"searchPlaceholder": "Поиск: художник, картина, источник…",
"refresh": "Обновить",
"loading": "Загрузка…",
"loadingParse": "Чтение файла…",
"loadingPreview": "Проверка связей влияния…",
"loadingCommit": "Импорт связей в базу…",
"loadingSave": "Сохранение связи…",
"addEdge": "Добавить связь",
"cancelAdd": "Отмена",
"total": "{{count}} связей",
"filtered": "фильтр по художнику",
"clearFilter": "Сбросить фильтр",
"subjectPainting": "Картина (субъект)",
"searchPainting": "Поиск картины…",
"sourceType": "Тип источника",
"typeArtist": "Художник",
"typePainting": "Картина",
"typeMovement": "Направление",
"sourceEntity": "Источник",
"searchSource": "Поиск источника…",
"notes": "Заметки",
"saveEdge": "Сохранить связь",
"addRequiresIds": "Выберите картину и источник",
"colSubject": "Субъект",
"colSource": "Источник",
"colType": "Тип",
"colNotes": "Заметки",
"colActions": "Действия",
"colAction": "Действие",
"colDirection": "Направление",
"delete": "Удалить",
"confirmDelete": "Удалить эту связь влияния?",
"noEdges": "Связи не найдены.",
"stepUpload": "1. Файл",
"stepMapping": "2. Столбцы",
"stepPreview": "3. Проверка",
"stepDone": "4. Готово",
"uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).",
"parseFailed": "Не удалось разобрать файл",
"previewFailed": "Не удалось построить превью",
"commitFailed": "Не удалось выполнить импорт",
"rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.",
"fileInfo": "{{name}} · {{rows}} строк · {{format}}",
"sheet": "Лист",
"preset": "Шаблон сопоставления",
"column": "Столбец",
"role": "Роль",
"sample": "Пример",
"backStep": "Назад",
"runPreview": "Проверить",
"previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}",
"warnings": "{{count}} предупреждений",
"commitImport": "Импортировать {{count}} связей",
"commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).",
"alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.",
"alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.",
"forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)",
"matchFile": "те же байты файла",
"matchData": "те же сопоставленные данные",
"searchArtist": "Поиск художника для графа…",
"graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.",
"graphStats": "{{nodes}} узлов · {{edges}} рёбер"
}
+22
View File
@@ -0,0 +1,22 @@
{
"aboutArtist": "О {{name}}",
"influencedBy": "Под влиянием",
"influenced": "Влияние на",
"noInfluences": "Для этой работы нет задокументированных влияний.",
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
"catalogPosition": "Позиция в каталоге",
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
"prevPainting": "Предыдущая картина",
"nextPainting": "Следующая картина",
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}",
"curatorNotes": "Заметки куратора",
"curatorNotesEmpty": "Заметок куратора пока нет.",
"curatorNotesEdit": "Изменить",
"curatorNotesSave": "Сохранить",
"curatorNotesSaving": "Сохранение…",
"curatorNotesCancel": "Отмена",
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
}
+10
View File
@@ -0,0 +1,10 @@
{
"label": "Поиск",
"placeholder": "Художники, картины, направления…",
"resultsLabel": "Результаты поиска",
"noMatches": "Ничего не найдено.",
"groupArtist": "Художники",
"groupMovement": "Направления",
"groupPainting": "Картины",
"searchFailed": "Ошибка поиска"
}
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Экскурсии",
"popupTitle": "Экскурсии",
"popupHint": "Выберите кураторскую подборку произведений.",
"close": "Закрыть",
"loading": "Загрузка…",
"loadFailed": "Не удалось загрузить экскурсии",
"noPublished": "Пока нет опубликованных экскурсий.",
"noTours": "Экскурсий пока нет. Создайте первую.",
"stopCount": "{{count}} остановок",
"back": "← В галерею",
"create": "Создать",
"newTourPlaceholder": "Название новой экскурсии…",
"selectTour": "Выберите экскурсию для редактирования.",
"tourTitle": "Название",
"tourDescription": "Описание",
"status": "Статус",
"draft": "Черновик",
"published": "Опубликовано",
"saveMeta": "Сохранить сведения",
"delete": "Удалить экскурсию",
"confirmDelete": "Удалить эту экскурсию и все остановки?",
"searchPainting": "Поиск картин для добавления…",
"saveStops": "Сохранить остановки",
"remove": "Убрать",
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
"noStops": "Остановок пока нет. Найдите и добавьте картины."
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Проверка переводов",
"back": "← В галерею",
"locale": "Язык",
"entityType": "Тип объекта",
"status": "Статус",
"all": "Все",
"draft": "Черновик",
"reviewed": "Проверено",
"published": "Опубликовано",
"coverage": "Охват",
"artistsBio": "Художники с биографией (ru)",
"paintingsTitle": "Картины с русским названием",
"canonical": "Английский (оригинал)",
"translation": "Перевод",
"saveDraft": "Сохранить черновик",
"publish": "Опубликовать",
"noRows": "Нет строк по выбранному фильтру.",
"loadFailed": "Не удалось загрузить переводы"
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Пользователи",
"back": "← Назад",
"loadFailed": "Не удалось загрузить пользователей",
"createTitle": "Создать пользователя",
"username": "Имя пользователя",
"password": "Пароль",
"role": "Роль",
"roleAdmin": "Администратор",
"roleCurator": "Куратор",
"permissions": "Права",
"active": "Активен",
"inactive": "Отключён",
"create": "Создать",
"creating": "Создание…",
"save": "Сохранить",
"saving": "Сохранение…",
"resetPassword": "Сбросить пароль",
"newPassword": "Новый пароль",
"lastLogin": "Последний вход",
"never": "Никогда",
"selectUser": "Выберите пользователя для редактирования",
"loading": "Загрузка…",
"perm_images": "Изображения (правка/загрузка)",
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
"deactivate": "Отключить учётную запись",
"activate": "Включить учётную запись",
"created": "Пользователь создан",
"saved": "Сохранено",
"passwordReset": "Пароль обновлён"
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n'
import App from './App.tsx'
import { AuthProvider } from './context/AuthContext.tsx'
+2
View File
@@ -54,6 +54,8 @@
padding: 24px 16px 8px;
flex-shrink: 0;
position: relative;
z-index: 110;
overflow: visible;
}
.site-dev-tools {
+568 -68
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
@@ -6,12 +7,37 @@ import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ArtistFilterModal from '../components/ArtistFilterModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/ArtistFilterModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import '../pages/UsersPage.css';
import { useAuth } from '../context/AuthContext';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type {
TimelineData,
Artist,
ArtistDetail,
Painting,
PaintingDetail,
MovementGalleryDetail,
TourGalleryDetail,
ArtistSummary,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
@@ -20,14 +46,22 @@ import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
type GallerySession =
| { kind: 'artist'; artistId: number; data: ArtistDetail }
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
@@ -40,6 +74,17 @@ function patchPaintingInMovementDetail(
};
}
function patchPaintingInTourDetail(
detail: TourGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): TourGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
@@ -61,7 +106,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
function patchReturnToAfterRemove(
returnTo: View,
freshArtist?: ArtistDetail,
freshMovement?: MovementGalleryDetail
freshMovement?: MovementGalleryDetail,
freshTour?: TourGalleryDetail
): View {
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
return { ...returnTo, data: freshArtist };
@@ -73,8 +119,14 @@ function patchReturnToAfterRemove(
) {
return { ...returnTo, data: freshMovement };
}
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
return { ...returnTo, data: freshTour };
}
if (returnTo.type === 'painting') {
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
return {
...returnTo,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
if (returnTo.type === 'bio') {
const data =
@@ -82,7 +134,7 @@ function patchReturnToAfterRemove(
return {
...returnTo,
data,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
return returnTo;
@@ -100,7 +152,15 @@ function catalogNavigateTarget(
}
export default function HomePage() {
const { isCurator, username, login, logout } = useAuth();
const { t } = useTranslation('home');
const { isCurator, username, login, logout, can } = useAuth();
const canImages = can('images');
const canCheckup = can('checkup');
const canNotes = can('curator_notes');
const canTranslations = can('translations');
const canInfluences = can('influences');
const canTours = can('tours');
const canUsers = can('users');
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
@@ -115,11 +175,18 @@ export default function HomePage() {
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [localeVersion, setLocaleVersion] = useState(0);
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null);
const effectiveDebugMode = debugMode && isCurator;
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const [artistFilterModal, setArtistFilterModal] = useState<{
movementId: number;
movementName: string;
artists: ArtistSummary[];
} | null>(null);
const effectiveDebugMode = debugMode && canImages;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
@@ -135,6 +202,8 @@ export default function HomePage() {
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
} else if (view.type === 'movement-gallery') {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
@@ -168,6 +237,11 @@ export default function HomePage() {
return () => {
cancelled = true;
};
}, [localeVersion]);
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
setApiLocale(locale);
setLocaleVersion((v) => v + 1);
}, []);
const viewChangeScheduler = useRef(
@@ -183,6 +257,17 @@ export default function HomePage() {
viewChangeScheduler.current.schedule(start, end);
}, []);
const goToTimelineHome = useCallback(() => {
detailReturnToRef.current = { type: 'timeline' };
setGallerySession(null);
setHoveredLifespan(null);
setGalleryEntryLoading(null);
setViewStart(bounds.min);
setViewEnd(bounds.max);
setGalleryRevision((revision) => revision + 1);
setView({ type: 'timeline' });
}, [bounds.min, bounds.max]);
const toggleDebugMode = () => {
setDebugMode((prev) => {
const next = !prev;
@@ -196,7 +281,7 @@ export default function HomePage() {
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: 'checkup' | null = null) => {
const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
@@ -206,6 +291,14 @@ export default function HomePage() {
setLoginOpen(false);
if (loginRedirect === 'checkup') {
setView({ type: 'checkup' });
} else if (loginRedirect === 'translations') {
setView({ type: 'translations' });
} else if (loginRedirect === 'influences') {
setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
} else if (loginRedirect === 'users') {
setView({ type: 'users' });
}
setLoginRedirect(null);
};
@@ -214,24 +307,64 @@ export default function HomePage() {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (view.type === 'checkup') {
setView({ type: 'timeline' });
if (
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours' ||
view.type === 'users'
) {
goToTimelineHome();
}
};
const openCheckup = () => {
if (!isCurator) {
if (!canCheckup) {
openCuratorLogin('checkup');
return;
}
setView({ type: 'checkup' });
};
const openTranslations = () => {
if (!canTranslations) {
openCuratorLogin('translations');
return;
}
setView({ type: 'translations' });
};
const openInfluences = () => {
if (!canInfluences) {
openCuratorLogin('influences');
return;
}
setView({ type: 'influences' });
};
const openToursEditor = () => {
if (!canTours) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
const openUsers = () => {
if (!canUsers) {
openCuratorLogin('users');
return;
}
setView({ type: 'users' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
image_path: fixResult.imagePath,
thumbnail_path: fixResult.thumbnailPath,
image_cache_key: fixResult.image_cache_key ?? null,
thumbnail_cache_key: fixResult.thumbnail_cache_key ?? null,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
@@ -257,6 +390,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -271,6 +410,9 @@ export default function HomePage() {
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
@@ -302,6 +444,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -316,12 +464,67 @@ export default function HomePage() {
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
},
[]
);
const handleCuratorNotesUpdated = useCallback((paintingId: number, curatorNotes: string) => {
const patch: Partial<Painting> = { curator_notes: curatorNotes };
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery') {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return {
...current,
data: {
...current.data,
painting: { ...current.data.painting, ...patch },
},
returnTo,
};
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) => {
if (session?.kind === 'artist') {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
@@ -359,6 +562,9 @@ export default function HomePage() {
const data = await api.getArtist(artistId);
const patch: Partial<Artist> = {
portrait_path: fixResult.portraitPath,
portrait_thumb_path: fixResult.portraitThumbPath ?? null,
portrait_cache_key: fixResult.portrait_cache_key ?? null,
portrait_thumb_cache_key: fixResult.portrait_thumb_cache_key ?? null,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
@@ -401,10 +607,36 @@ export default function HomePage() {
setView({ type: 'movement-gallery', movementId, data });
}, []);
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
const session: GallerySession = { kind: 'tour', tourId, data };
setGallerySession(session);
setView({ type: 'tour-gallery', tourId, data });
}, []);
const handleSelectPublishedTour = useCallback(
async (tourId: number) => {
setToursPopupOpen(false);
setGalleryEntryLoading(t('openingTourGallery'));
try {
const data = await api.getTour(tourId);
if (!data.paintings.length) {
setError(t('tourEmpty'));
return;
}
openTourGallery(tourId, data);
} catch {
setError(t('tourLoadFailed'));
} finally {
setGalleryEntryLoading(null);
}
},
[openTourGallery, t]
);
const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…');
try {
await api.preloadArtistImages(artistId).catch(() => undefined);
void api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId);
openArtistGallery(artistId, data);
} catch {
@@ -415,10 +647,46 @@ export default function HomePage() {
};
const handleMovementClick = async (movementId: number) => {
setGalleryEntryLoading('Loading artists…');
try {
const artists = await api.getMovementArtistsSummary(movementId);
if (artists.length === 0) {
setError('No artists in this movement.');
return;
}
const movement = timelineData.movements.find((m) => m.id === movementId);
setArtistFilterModal({
movementId,
movementName: movement?.name ?? 'Movement',
artists,
});
} catch {
setError('Failed to load movement artists.');
} finally {
setGalleryEntryLoading(null);
}
};
const handleArtistFilterProceed = async (selectedIds: Set<number>) => {
if (!artistFilterModal) return;
const { movementId } = artistFilterModal;
setArtistFilterModal(null);
setGalleryEntryLoading('Opening movement gallery…');
try {
// Do not await preload — it only syncs disk paths and must not delay hall open.
// Fire it in parallel so any missing thumbs regenerate while the gallery boots.
const preloadPromise = api.preloadMovementImages(movementId).catch(() => undefined);
const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data);
void preloadPromise;
const filtered: MovementGalleryDetail = {
...data,
paintings: data.paintings.filter((p) => selectedIds.has(Number(p.artist_id))),
};
if (filtered.paintings.length === 0) {
setError('No paintings for the selected artists.');
return;
}
openMovementGallery(movementId, filtered);
} catch {
setError('Failed to load movement gallery.');
} finally {
@@ -463,25 +731,38 @@ export default function HomePage() {
const currentView = viewRef.current;
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
const sorted =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
? detailArtistPaintings
: sortArtistPaintingsChronological(detailArtistPaintings);
const nextId = catalogNavigateTarget(sorted, paintingId);
const inMovementCatalog =
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
const inTourCatalog =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
await api.deletePainting(paintingId);
const freshArtist = await api.getArtist(artistId);
let freshMovement: MovementGalleryDetail | undefined;
let freshTour: TourGalleryDetail | undefined;
if (gallerySession?.kind === 'movement') {
freshMovement = await api.getMovementGallery(gallerySession.movementId);
} else if (currentView.returnTo.type === 'movement-gallery') {
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
}
if (gallerySession?.kind === 'tour') {
freshTour = await api.getTour(gallerySession.tourId);
} else if (currentView.returnTo.type === 'tour-gallery') {
freshTour = await api.getTour(currentView.returnTo.tourId);
}
const freshCatalog =
inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
inTourCatalog && freshTour
? freshTour.paintings
: inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
const navigateId =
@@ -502,6 +783,9 @@ export default function HomePage() {
if (session.kind === 'movement' && freshMovement) {
return { ...session, data: freshMovement };
}
if (session.kind === 'tour' && freshTour) {
return { ...session, data: freshTour };
}
return session;
});
@@ -516,7 +800,8 @@ export default function HomePage() {
const patchedReturnTo = patchReturnToAfterRemove(
currentView.returnTo,
freshArtist,
freshMovement
freshMovement,
freshTour
);
detailReturnToRef.current = patchedReturnTo;
@@ -527,6 +812,9 @@ export default function HomePage() {
if (current.type === 'movement-gallery' && freshMovement) {
return { ...current, data: freshMovement };
}
if (current.type === 'tour-gallery' && freshTour) {
return { ...current, data: freshTour };
}
if (current.type !== 'painting' || current.paintingId !== paintingId) {
return current;
}
@@ -558,12 +846,20 @@ export default function HomePage() {
}
const artistId = view.data.painting.artist_id;
if (gallerySession?.kind === 'tour') {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
const returnTo = detailReturnToRef.current;
if (returnTo.type === 'tour-gallery') {
setDetailArtistPaintings(returnTo.data.paintings);
return;
}
if (returnTo.type === 'movement-gallery') {
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
return;
@@ -583,12 +879,31 @@ export default function HomePage() {
};
}, [view, gallerySession]);
const sortedDetailArtistPaintings = useMemo(
() => sortArtistPaintingsChronological(detailArtistPaintings),
[detailArtistPaintings]
);
const sortedDetailArtistPaintings = useMemo(() => {
const fromTourSession = gallerySession?.kind === 'tour';
const fromTourReturn =
view.type === 'painting' && view.returnTo.type === 'tour-gallery';
if (fromTourSession || fromTourReturn) {
return detailArtistPaintings;
}
return sortArtistPaintingsChronological(detailArtistPaintings);
}, [detailArtistPaintings, gallerySession, view]);
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
const tourOverlay =
view.type === 'painting' && gallerySession?.kind === 'tour'
? {
title: gallerySession.data.tour.title,
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
}
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
? {
title: view.returnTo.data.tour.title,
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
}
: null;
const galleryActive =
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
const displayGallery = useMemo((): GallerySession | null => {
if (view.type === 'gallery') {
@@ -597,6 +912,9 @@ export default function HomePage() {
if (view.type === 'movement-gallery') {
return { kind: 'movement', movementId: view.movementId, data: view.data };
}
if (view.type === 'tour-gallery') {
return { kind: 'tour', tourId: view.tourId, data: view.data };
}
return gallerySession;
}, [view, gallerySession]);
@@ -616,7 +934,7 @@ export default function HomePage() {
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBack={goToTimelineHome}
onBioClick={() =>
handleBioClick(displayGallery.data, {
type: 'gallery',
@@ -625,7 +943,7 @@ export default function HomePage() {
})
}
/>
) : (
) : displayGallery.kind === 'movement' ? (
<VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
mode="movement"
@@ -633,7 +951,17 @@ export default function HomePage() {
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={() => setView({ type: 'timeline' })}
onBack={goToTimelineHome}
/>
) : (
<VirtualGallery
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
mode="tour"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
)}
</div>
@@ -645,7 +973,14 @@ export default function HomePage() {
key={view.paintingId}
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
tourTitle={tourOverlay?.title ?? null}
tourText={tourOverlay ? tourOverlay.text : null}
onBack={() => {
if (view.returnTo.type === 'timeline') {
goToTimelineHome();
return;
}
const returnTo = view.returnTo;
if (
returnTo.type === 'gallery' &&
@@ -659,10 +994,18 @@ export default function HomePage() {
gallerySession.movementId === returnTo.movementId
) {
openMovementGallery(gallerySession.movementId, gallerySession.data);
} else if (
returnTo.type === 'tour-gallery' &&
gallerySession?.kind === 'tour' &&
gallerySession.tourId === returnTo.tourId
) {
openTourGallery(gallerySession.tourId, gallerySession.data);
} else if (returnTo.type === 'gallery') {
openArtistGallery(returnTo.artistId, returnTo.data);
} else if (returnTo.type === 'movement-gallery') {
openMovementGallery(returnTo.movementId, returnTo.data);
} else if (returnTo.type === 'tour-gallery') {
openTourGallery(returnTo.tourId, returnTo.data);
} else {
setView(returnTo);
}
@@ -674,11 +1017,14 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
isCurator={canNotes}
canCheckup={canCheckup}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
onCuratorNotesUpdated={handleCuratorNotesUpdated}
/>
</div>
)}
@@ -688,7 +1034,8 @@ export default function HomePage() {
<ArtistBio
artist={view.data.artist}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
canCheckup={canCheckup}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
@@ -700,22 +1047,98 @@ export default function HomePage() {
</div>
)}
{view.type === 'influences' && (
canInfluences ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('influences')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'tours' && (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'users' && (
canUsers ? (
<UsersPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('users')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && (
canTranslations ? (
<TranslationsPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'checkup' && (
isCurator ? (
canCheckup ? (
<CheckupPage
onBack={() => setView({ type: 'timeline' })}
onBack={goToTimelineHome}
onOpenPainting={handlePaintingClick}
/>
) : (
<div className="curator-login-gate">
<h2>Curator access required</h2>
<p>The painting checkup table is available to logged-in curators only.</p>
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
Curator login
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={() => setView({ type: 'timeline' })}>
Back to gallery
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
@@ -731,6 +1154,23 @@ export default function HomePage() {
onLogin={handleCuratorLogin}
/>
<ToursPopup
open={toursPopupOpen}
onClose={() => setToursPopupOpen(false)}
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{artistFilterModal && (
<ArtistFilterModal
open
movementName={artistFilterModal.movementName}
artists={artistFilterModal.artists}
portraitRevisions={portraitRevisions}
onClose={() => setArtistFilterModal(null)}
onProceed={(selectedIds) => void handleArtistFilterProceed(selectedIds)}
/>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
@@ -740,40 +1180,86 @@ export default function HomePage() {
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
Checkup
</button>
{canImages && (
<>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
</>
)}
{canInfluences && (
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
)}
{canTours && (
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
)}
{canTranslations && (
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
)}
{canCheckup && (
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{t('checkup')}
</button>
)}
{canUsers && (
<button
type="button"
className="checkup-link-btn"
onClick={openUsers}
title="Manage curator accounts"
>
{t('users')}
</button>
)}
<button
type="button"
className="curator-logout-btn"
onClick={handleCuratorLogout}
title="Sign out curator session"
>
Logout
{t('curatorLogout')}
</button>
</>
) : (
@@ -783,12 +1269,26 @@ export default function HomePage() {
onClick={() => openCuratorLogin()}
title="Sign in as curator to use debug tools"
>
Curator login
{t('curatorLogin')}
</button>
)}
<button
type="button"
className="checkup-link-btn"
onClick={() => setToursPopupOpen(true)}
title={t('tours')}
>
{t('tours')}
</button>
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div>
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p>
<h1>{t('title')}</h1>
<p className="site-subtitle">{t('subtitle')}</p>
<CatalogSearchBar
onSelectArtist={handleArtistClick}
onSelectMovement={handleMovementClick}
onSelectPainting={handlePaintingClick}
/>
</header>
<div className="home-timeline-stack">
+378
View File
@@ -0,0 +1,378 @@
.influences-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
position: relative;
}
.influences-loading-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(10, 10, 20, 0.72);
pointer-events: auto;
}
.influences-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 1rem;
}
.influences-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 650;
flex: 1;
color: #e8d5b5;
}
.influences-back {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.influences-back:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-tabs {
display: flex;
gap: 0.35rem;
}
.influences-tabs button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.influences-tabs button:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-tabs button.active {
background: rgba(232, 160, 64, 0.2);
color: #e8d5b5;
border-color: #e8a040;
}
.influences-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.influences-success {
background: rgba(22, 101, 52, 0.35);
border: 1px solid rgba(134, 239, 172, 0.35);
color: #bbf7d0;
padding: 0.65rem 0.85rem;
border-radius: 6px;
}
.influences-dup-warn {
background: rgba(120, 53, 15, 0.45);
border: 1px solid rgba(251, 191, 36, 0.45);
color: #fde68a;
padding: 0.75rem 0.9rem;
border-radius: 8px;
display: grid;
gap: 0.65rem;
}
.influences-dup-warn p {
margin: 0;
color: #fde68a;
}
.influences-force {
display: flex;
align-items: center;
gap: 0.5rem;
color: #e8d5b5;
font-size: 0.9rem;
}
.influences-force input {
accent-color: #e8a040;
min-width: auto;
}
.influences-panel {
display: flex;
flex-direction: column;
gap: 1rem;
color: #e8d5b5;
}
.influences-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.influences-toolbar input[type='search'],
.influences-add input,
.influences-add textarea,
.influences-add select,
.wizard-block select,
.wizard-block input {
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
padding: 0.45rem 0.65rem;
min-width: 12rem;
font: inherit;
background: rgba(15, 15, 26, 0.85);
color: #e8d5b5;
}
.influences-toolbar input::placeholder,
.influences-add input::placeholder,
.wizard-block input::placeholder {
color: rgba(201, 169, 110, 0.5);
}
.influences-toolbar button,
.wizard-actions button,
.influences-add button,
.influences-hits button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font: inherit;
}
.influences-toolbar button:hover,
.wizard-actions button:hover,
.influences-add button:hover,
.influences-hits button:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-meta {
color: rgba(201, 169, 110, 0.75);
font-size: 0.9rem;
}
.influences-add {
display: grid;
gap: 0.65rem;
padding: 1rem;
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
background: rgba(15, 15, 26, 0.55);
color: #e8d5b5;
}
.influences-add label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: #e8d5b5;
}
.influences-hits {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.influences-table-wrap {
overflow: auto;
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
}
.influences-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
color: #e8d5b5;
}
.influences-table th,
.influences-table td {
border-bottom: 1px solid rgba(201, 169, 110, 0.15);
padding: 0.55rem 0.65rem;
text-align: left;
vertical-align: top;
color: #e8d5b5;
}
.influences-table th {
background: rgba(15, 15, 26, 0.85);
font-weight: 600;
color: #c9a96e;
}
.notes-cell {
max-width: 18rem;
white-space: pre-wrap;
word-break: break-word;
}
.muted {
color: rgba(201, 169, 110, 0.65);
font-size: 0.85rem;
}
.linkish {
background: none;
border: none;
color: #e8a040;
cursor: pointer;
padding: 0;
font: inherit;
text-align: left;
}
.linkish:hover {
color: #e8d5b5;
}
.danger {
color: #ffaaaa !important;
border-color: rgba(255, 170, 170, 0.4) !important;
}
.wizard-steps {
display: flex;
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
.wizard-steps li {
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(15, 15, 26, 0.85);
border: 1px solid rgba(201, 169, 110, 0.25);
color: rgba(201, 169, 110, 0.75);
font-size: 0.85rem;
}
.wizard-steps li.active {
background: rgba(232, 160, 64, 0.2);
border-color: #e8a040;
color: #e8d5b5;
}
.wizard-block {
display: grid;
gap: 0.85rem;
color: #e8d5b5;
}
.wizard-block p,
.wizard-block label,
.wizard-block summary {
color: #e8d5b5;
}
.wizard-actions {
display: flex;
gap: 0.5rem;
}
.warnings-list {
max-height: 12rem;
overflow: auto;
font-size: 0.85rem;
color: #fbbf24;
}
.mapping-table select {
min-width: 10rem;
}
.influences-graph h2 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
color: #e8d5b5;
}
.influences-svg {
width: 100%;
max-width: 760px;
height: auto;
background: rgba(15, 15, 26, 0.65);
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 10px;
}
.influences-svg .edge-in {
stroke: rgba(201, 169, 110, 0.55);
stroke-width: 1.5;
opacity: 0.85;
}
.influences-svg .edge-out {
stroke: #e8a040;
stroke-width: 1.5;
opacity: 0.85;
}
.influences-svg .node circle {
fill: rgba(201, 169, 110, 0.35);
stroke: #c9a96e;
stroke-width: 1.5;
cursor: pointer;
}
.influences-svg .node-artist circle {
fill: rgba(96, 165, 250, 0.35);
stroke: #93c5fd;
}
.influences-svg .node-movement circle {
fill: rgba(74, 222, 128, 0.3);
stroke: #86efac;
}
.influences-svg .node-painting circle {
fill: rgba(251, 146, 60, 0.3);
stroke: #fdba74;
}
.influences-svg .node.focus circle {
fill: #e8a040;
stroke: #e8d5b5;
}
.influences-svg .node text {
font-size: 10px;
fill: #e8d5b5;
pointer-events: none;
}
.influences-svg .node.focus text {
font-weight: 650;
fill: #e8d5b5;
}
+831
View File
@@ -0,0 +1,831 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
api,
type InfluenceEdgeItem,
type InfluenceGraph,
type InfluenceImportParseResult,
type InfluenceImportPreview,
type InfluenceImportProposal,
} from '../api/client';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import './InfluencesPage.css';
type Tab = 'list' | 'import' | 'graph';
type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
interface Props {
onBack: () => void;
}
const ROLE_OPTIONS = [
'ignore',
'subject_artist',
'subject_painting',
'influenced_by',
'influenced',
'notes',
'reference',
'source_url',
] as const;
export default function InfluencesPage({ onBack }: Props) {
const { t } = useTranslation('influences');
const [tab, setTab] = useState<Tab>('list');
const [error, setError] = useState<string | null>(null);
// List
const [items, setItems] = useState<InfluenceEdgeItem[]>([]);
const [total, setTotal] = useState(0);
const [q, setQ] = useState('');
const [loading, setLoading] = useState(false);
const [filterArtistId, setFilterArtistId] = useState<number | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addPaintingQuery, setAddPaintingQuery] = useState('');
const [addSourceQuery, setAddSourceQuery] = useState('');
const [addSourceType, setAddSourceType] = useState<'artist' | 'painting' | 'movement'>('artist');
const [addPaintingId, setAddPaintingId] = useState<number | null>(null);
const [addSourceId, setAddSourceId] = useState<number | null>(null);
const [addNotes, setAddNotes] = useState('');
const [searchHits, setSearchHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
const [sourceHits, setSourceHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
// Graph
const [graphArtistQuery, setGraphArtistQuery] = useState('');
const [graphArtistId, setGraphArtistId] = useState<number | null>(null);
const [graphArtistHits, setGraphArtistHits] = useState<Array<{ id: number; label: string }>>([]);
const [graph, setGraph] = useState<InfluenceGraph | null>(null);
// Import wizard
const [wizardStep, setWizardStep] = useState<WizardStep>('upload');
const [parseResult, setParseResult] = useState<InfluenceImportParseResult | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [selectedSheet, setSelectedSheet] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [preview, setPreview] = useState<InfluenceImportPreview | null>(null);
const [busy, setBusy] = useState(false);
const [busyMessage, setBusyMessage] = useState('');
const [commitResult, setCommitResult] = useState<{ inserted: number; skipped: number } | null>(null);
const [forceImport, setForceImport] = useState(false);
const startBusy = (message: string) => {
setBusyMessage(message);
setBusy(true);
};
const stopBusy = () => {
setBusy(false);
setBusyMessage('');
};
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listInfluences({
q: q || undefined,
artistId: filterArtistId || undefined,
limit: 200,
});
setItems(data.items);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [q, filterArtistId, t]);
useEffect(() => {
if (tab === 'list') void loadList();
}, [tab, loadList]);
const runSearch = async (query: string, types: string) => {
if (query.trim().length < 2) return [] as Array<{ type: string; id: number; label: string }>;
const data = await api.search(query.trim(), { types, limit: 12 });
return data.results.map((r) => {
if (r.type === 'painting') {
return { type: r.type, id: r.id, label: `${r.artist_name}${r.title}` };
}
return { type: r.type, id: r.id, label: r.name };
});
};
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
setSearchHits(await runSearch(addPaintingQuery, 'painting'));
} catch {
setSearchHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [addPaintingQuery]);
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
setSourceHits(await runSearch(addSourceQuery, addSourceType));
} catch {
setSourceHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [addSourceQuery, addSourceType]);
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
const hits = await runSearch(graphArtistQuery, 'artist');
setGraphArtistHits(hits.filter((h) => h.type === 'artist').map((h) => ({ id: h.id, label: h.label })));
} catch {
setGraphArtistHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [graphArtistQuery]);
const loadGraph = async (artistId: number) => {
setError(null);
try {
const data = await api.getInfluenceGraph({ artistId });
setGraph(data);
setGraphArtistId(artistId);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const handleDelete = async (id: number) => {
if (!window.confirm(t('confirmDelete'))) return;
try {
await api.deleteInfluence(id);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const handleCreate = async () => {
if (!addPaintingId || !addSourceId) {
setError(t('addRequiresIds'));
return;
}
startBusy(t('loadingSave'));
setError(null);
try {
const payload: Parameters<typeof api.createInfluence>[0] = {
paintingId: addPaintingId,
sourceType: addSourceType,
notes: addNotes || undefined,
source: 'curator-ui',
};
if (addSourceType === 'artist') payload.sourceArtistId = addSourceId;
if (addSourceType === 'painting') payload.sourcePaintingId = addSourceId;
if (addSourceType === 'movement') payload.sourceMovementId = addSourceId;
await api.createInfluence(payload);
setShowAdd(false);
setAddPaintingId(null);
setAddSourceId(null);
setAddNotes('');
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
stopBusy();
}
};
const onFileChosen = async (file: File | null) => {
if (!file) return;
setPendingFile(file);
startBusy(t('loadingParse'));
setError(null);
setCommitResult(null);
setForceImport(false);
try {
const parsed = await api.parseInfluenceImport(file);
setParseResult(parsed);
setMapping(parsed.suggestedMapping);
setSelectedSheet(parsed.sheet);
setWizardStep('mapping');
setPreview(null);
} catch (err) {
setError(err instanceof Error ? err.message : t('parseFailed'));
} finally {
stopBusy();
}
};
const reparseSheet = async (sheet: string) => {
if (!pendingFile) return;
startBusy(t('loadingParse'));
try {
const parsed = await api.parseInfluenceImport(pendingFile, sheet);
setParseResult(parsed);
setMapping(parsed.suggestedMapping);
setSelectedSheet(sheet);
} catch (err) {
setError(err instanceof Error ? err.message : t('parseFailed'));
} finally {
stopBusy();
}
};
const applyPreset = (presetId: string) => {
if (!parseResult) return;
const preset = parseResult.presets.find((p) => p.id === presetId);
if (!preset) return;
const next: Record<string, string> = {};
for (const col of parseResult.columns) next[col] = 'ignore';
for (const [col, role] of Object.entries(preset.mapping)) {
if (parseResult.columns.includes(col)) next[col] = role;
}
// Fill gaps with auto suggestions
for (const [col, role] of Object.entries(parseResult.suggestedMapping)) {
if (next[col] === 'ignore' && role !== 'ignore') next[col] = role;
}
setMapping(next);
};
const runPreview = async () => {
if (!parseResult?.rows) {
setError(t('rowsMissing'));
return;
}
startBusy(t('loadingPreview'));
setError(null);
try {
const result = await api.previewInfluenceImport({
rows: parseResult.rows,
mapping,
sourceLabel: parseResult.filename,
contentHash: parseResult.contentHash,
payloadHash: parseResult.payloadHash,
});
setPreview(result);
setWizardStep('preview');
if (result.alreadyImported) setForceImport(false);
} catch (err) {
setError(err instanceof Error ? err.message : t('previewFailed'));
} finally {
stopBusy();
}
};
const runCommit = async () => {
if (!preview) return;
const already = preview.alreadyImported || parseResult?.alreadyImported;
if (already && !forceImport) {
setError(t('alreadyImportedBlock'));
return;
}
startBusy(t('loadingCommit'));
setError(null);
try {
const result = await api.commitInfluenceImport({
proposals: preview.proposals,
fileName: parseResult?.filename,
contentHash: preview.contentHash || parseResult?.contentHash,
payloadHash: preview.payloadHash || parseResult?.payloadHash,
force: forceImport,
});
setCommitResult(result);
setWizardStep('done');
await loadList();
} catch (err) {
const e = err as Error & { code?: string };
if (e.code === 'ALREADY_IMPORTED') {
setError(t('alreadyImportedBlock'));
} else {
setError(err instanceof Error ? err.message : t('commitFailed'));
}
} finally {
stopBusy();
}
};
const priorImport = preview?.priorImport || parseResult?.priorImport || null;
const alreadyImported = Boolean(preview?.alreadyImported || parseResult?.alreadyImported);
const createProposals = useMemo(
() => (preview?.proposals || []).filter((p) => p.action === 'create').slice(0, 200),
[preview],
);
return (
<div className="influences-page">
{busy && (
<GalleryLoadingMarker
overlay
className="influences-loading-overlay"
message={busyMessage || t('loading')}
/>
)}
<header className="influences-header">
<button type="button" className="influences-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
<nav className="influences-tabs">
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
{t('tabList')}
</button>
<button type="button" className={tab === 'import' ? 'active' : ''} onClick={() => setTab('import')}>
{t('tabImport')}
</button>
<button type="button" className={tab === 'graph' ? 'active' : ''} onClick={() => setTab('graph')}>
{t('tabGraph')}
</button>
</nav>
</header>
{error && <div className="influences-error">{error}</div>}
{tab === 'list' && (
<section className="influences-panel">
<div className="influences-toolbar">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('searchPlaceholder')}
/>
<button type="button" onClick={() => void loadList()} disabled={loading}>
{loading ? t('loading') : t('refresh')}
</button>
<button type="button" onClick={() => setShowAdd((v) => !v)}>
{showAdd ? t('cancelAdd') : t('addEdge')}
</button>
<span className="influences-meta">
{t('total', { count: total })}
{filterArtistId ? ` · ${t('filtered')}` : ''}
</span>
{filterArtistId && (
<button
type="button"
onClick={() => {
setFilterArtistId(null);
}}
>
{t('clearFilter')}
</button>
)}
</div>
{showAdd && (
<div className="influences-add">
<label>
{t('subjectPainting')}
<input
value={addPaintingQuery}
onChange={(e) => {
setAddPaintingQuery(e.target.value);
setAddPaintingId(null);
}}
placeholder={t('searchPainting')}
/>
</label>
{searchHits.length > 0 && !addPaintingId && (
<ul className="influences-hits">
{searchHits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<button
type="button"
onClick={() => {
setAddPaintingId(h.id);
setAddPaintingQuery(h.label);
}}
>
{h.label}
</button>
</li>
))}
</ul>
)}
<label>
{t('sourceType')}
<select
value={addSourceType}
onChange={(e) => {
setAddSourceType(e.target.value as 'artist' | 'painting' | 'movement');
setAddSourceId(null);
setAddSourceQuery('');
}}
>
<option value="artist">{t('typeArtist')}</option>
<option value="painting">{t('typePainting')}</option>
<option value="movement">{t('typeMovement')}</option>
</select>
</label>
<label>
{t('sourceEntity')}
<input
value={addSourceQuery}
onChange={(e) => {
setAddSourceQuery(e.target.value);
setAddSourceId(null);
}}
placeholder={t('searchSource')}
/>
</label>
{sourceHits.length > 0 && !addSourceId && (
<ul className="influences-hits">
{sourceHits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<button
type="button"
onClick={() => {
setAddSourceId(h.id);
setAddSourceQuery(h.label);
}}
>
{h.label}
</button>
</li>
))}
</ul>
)}
<label>
{t('notes')}
<textarea value={addNotes} onChange={(e) => setAddNotes(e.target.value)} rows={2} />
</label>
<button type="button" disabled={busy} onClick={() => void handleCreate()}>
{t('saveEdge')}
</button>
</div>
)}
<div className="influences-table-wrap">
<table className="influences-table">
<thead>
<tr>
<th>{t('colSubject')}</th>
<th>{t('colSource')}</th>
<th>{t('colType')}</th>
<th>{t('colNotes')}</th>
<th>{t('colActions')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<button
type="button"
className="linkish"
onClick={() => {
setFilterArtistId(item.artistId);
setTab('graph');
void loadGraph(item.artistId);
}}
>
{item.artistName}
</button>
<div className="muted">{item.paintingTitle}</div>
</td>
<td>{item.sourceLabel || '—'}</td>
<td>{item.sourceType}</td>
<td className="notes-cell">{item.notes || '—'}</td>
<td>
<button type="button" className="danger" onClick={() => void handleDelete(item.id)}>
{t('delete')}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td colSpan={5}>{t('noEdges')}</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
)}
{tab === 'import' && (
<section className="influences-panel">
<ol className="wizard-steps">
<li className={wizardStep === 'upload' ? 'active' : ''}>{t('stepUpload')}</li>
<li className={wizardStep === 'mapping' ? 'active' : ''}>{t('stepMapping')}</li>
<li className={wizardStep === 'preview' ? 'active' : ''}>{t('stepPreview')}</li>
<li className={wizardStep === 'done' ? 'active' : ''}>{t('stepDone')}</li>
</ol>
{(wizardStep === 'upload' || wizardStep === 'done') && (
<div className="wizard-block">
<p>{t('uploadHelp')}</p>
<input
type="file"
accept=".csv,.json,.xlsx,.xls,application/json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(e) => void onFileChosen(e.target.files?.[0] || null)}
disabled={busy}
/>
{commitResult && (
<p className="influences-success">
{t('commitSummary', { inserted: commitResult.inserted, skipped: commitResult.skipped })}
</p>
)}
</div>
)}
{wizardStep === 'mapping' && parseResult && (
<div className="wizard-block">
<p>
{t('fileInfo', {
name: parseResult.filename,
rows: parseResult.rowCount,
format: parseResult.format,
})}
</p>
{alreadyImported && priorImport && (
<div className="influences-dup-warn">
<p>
{t('alreadyImportedWarn', {
when: new Date(priorImport.importedAt).toLocaleString(),
who: priorImport.username || '—',
file: priorImport.fileName || parseResult.filename,
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
})}
</p>
</div>
)}
{parseResult.sheets && parseResult.sheets.length > 1 && (
<label>
{t('sheet')}
<select
value={selectedSheet || ''}
onChange={(e) => void reparseSheet(e.target.value)}
>
{parseResult.sheets.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
)}
<label>
{t('preset')}
<select
defaultValue={parseResult.suggestedPreset}
onChange={(e) => applyPreset(e.target.value)}
>
{parseResult.presets.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<table className="influences-table mapping-table">
<thead>
<tr>
<th>{t('column')}</th>
<th>{t('role')}</th>
<th>{t('sample')}</th>
</tr>
</thead>
<tbody>
{parseResult.columns.map((col) => (
<tr key={col}>
<td>{col}</td>
<td>
<select
value={mapping[col] || 'ignore'}
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
>
{ROLE_OPTIONS.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</td>
<td className="muted">
{parseResult.sampleRows[0]?.[col]?.slice(0, 80) || '—'}
</td>
</tr>
))}
</tbody>
</table>
<div className="wizard-actions">
<button type="button" onClick={() => setWizardStep('upload')}>
{t('backStep')}
</button>
<button type="button" disabled={busy || !parseResult.rows} onClick={() => void runPreview()}>
{t('runPreview')}
</button>
</div>
</div>
)}
{wizardStep === 'preview' && preview && (
<div className="wizard-block">
<p>
{t('previewCounts', {
create: preview.counts.willCreate,
skip: preview.counts.willSkip,
errors: preview.counts.errors,
proposals: preview.counts.proposals,
})}
</p>
{alreadyImported && priorImport && (
<div className="influences-dup-warn">
<p>
{t('alreadyImportedWarn', {
when: new Date(priorImport.importedAt).toLocaleString(),
who: priorImport.username || '—',
file: priorImport.fileName || parseResult?.filename || '—',
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
})}
</p>
<label className="influences-force">
<input
type="checkbox"
checked={forceImport}
onChange={(e) => setForceImport(e.target.checked)}
/>
{t('forceImport')}
</label>
</div>
)}
{preview.warnings.length > 0 && (
<details open={preview.warnings.length < 30}>
<summary>
{t('warnings', { count: preview.warnings.length })}
</summary>
<ul className="warnings-list">
{preview.warnings.slice(0, 80).map((w, i) => (
<li key={`${w.rowIndex}-${i}`}>
#{w.rowIndex + 1}: {w.message}
</li>
))}
</ul>
</details>
)}
<div className="influences-table-wrap">
<table className="influences-table">
<thead>
<tr>
<th>{t('colAction')}</th>
<th>{t('colSubject')}</th>
<th>{t('colSource')}</th>
<th>{t('colType')}</th>
<th>{t('colDirection')}</th>
</tr>
</thead>
<tbody>
{createProposals.map((p: InfluenceImportProposal) => (
<tr key={p.edgeKey}>
<td>{p.action}</td>
<td>
{p.artistName}
<div className="muted">{p.paintingTitle}</div>
</td>
<td>{p.sourceLabel}</td>
<td>{p.sourceType}</td>
<td>{p.direction}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="wizard-actions">
<button type="button" onClick={() => setWizardStep('mapping')}>
{t('backStep')}
</button>
<button
type="button"
disabled={
busy
|| preview.counts.willCreate === 0
|| (alreadyImported && !forceImport)
}
onClick={() => void runCommit()}
>
{t('commitImport', { count: preview.counts.willCreate })}
</button>
</div>
</div>
)}
</section>
)}
{tab === 'graph' && (
<section className="influences-panel">
<div className="influences-toolbar">
<input
type="search"
value={graphArtistQuery}
onChange={(e) => setGraphArtistQuery(e.target.value)}
placeholder={t('searchArtist')}
/>
</div>
{graphArtistHits.length > 0 && (
<ul className="influences-hits">
{graphArtistHits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => void loadGraph(h.id)}>
{h.label}
</button>
</li>
))}
</ul>
)}
{graph && (
<>
{graphArtistId != null && (
<p className="muted">Artist id: {graphArtistId}</p>
)}
<InfluenceGraphSvg graph={graph} onSelectArtist={(id) => {
setFilterArtistId(id);
setTab('list');
}} />
</>
)}
{!graph && <p className="muted">{t('graphEmpty')}</p>}
</section>
)}
</div>
);
}
function InfluenceGraphSvg({
graph,
onSelectArtist,
}: {
graph: InfluenceGraph;
onSelectArtist: (artistId: number) => void;
}) {
const { t } = useTranslation('influences');
const width = 720;
const height = 420;
const cx = width / 2;
const cy = height / 2;
const focus = graph.nodes.find((n) => n.focus) || graph.nodes[0];
const others = graph.nodes.filter((n) => n.id !== focus?.id);
const positions = new Map<string, { x: number; y: number }>();
if (focus) positions.set(focus.id, { x: cx, y: cy });
others.forEach((n, i) => {
const angle = (Math.PI * 2 * i) / Math.max(others.length, 1) - Math.PI / 2;
const r = 140 + (i % 3) * 28;
positions.set(n.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
});
return (
<div className="influences-graph">
<h2>{graph.focus.label}</h2>
<p className="muted">
{t('graphStats', { nodes: graph.nodes.length, edges: graph.edges.length })}
</p>
<svg viewBox={`0 0 ${width} ${height}`} className="influences-svg" role="img">
{graph.edges.map((e) => {
const a = positions.get(e.from);
const b = positions.get(e.to);
if (!a || !b) return null;
return (
<line
key={`${e.id}-${e.from}-${e.to}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
className={e.direction === 'influenced_by' ? 'edge-in' : 'edge-out'}
/>
);
})}
{graph.nodes.map((n) => {
const p = positions.get(n.id);
if (!p) return null;
return (
<g
key={n.id}
transform={`translate(${p.x},${p.y})`}
className={`node node-${n.type}${n.focus ? ' focus' : ''}`}
onClick={() => {
if (n.artistId) onSelectArtist(n.artistId);
}}
>
<circle r={n.focus ? 22 : 14} />
<title>{n.label}</title>
<text y={n.focus ? 36 : 28} textAnchor="middle">
{n.label.length > 28 ? `${n.label.slice(0, 26)}` : n.label}
</text>
</g>
);
})}
</svg>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
.tours-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
}
.tours-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.tours-header h1 {
margin: 0;
font-size: 1.5rem;
color: #e8d5b5;
}
.tours-back {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.tours-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.tours-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1rem;
}
.tours-list-panel,
.tours-editor {
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
padding: 0.85rem;
background: rgba(15, 15, 26, 0.45);
}
.tours-create {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.tours-create input,
.tours-meta input,
.tours-meta textarea,
.tours-meta select,
.tours-stops-toolbar input,
.tours-stops textarea {
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
padding: 0.45rem 0.65rem;
background: rgba(15, 15, 26, 0.85);
color: #e8d5b5;
font: inherit;
width: 100%;
}
.tours-create button,
.tours-meta-actions button,
.tours-stops-toolbar button,
.tours-stop-move button,
.tours-hits button,
.tours-list button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.65rem;
cursor: pointer;
font: inherit;
}
.tours-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
max-height: 70vh;
overflow: auto;
}
.tours-list button {
width: 100%;
text-align: left;
display: grid;
gap: 0.15rem;
}
.tours-list button.active {
border-color: #e8a040;
color: #e8d5b5;
background: rgba(232, 160, 64, 0.15);
}
.tours-meta {
display: grid;
gap: 0.65rem;
margin-bottom: 1rem;
}
.tours-meta label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
}
.tours-meta-actions {
display: flex;
gap: 0.5rem;
}
.tours-stops-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tours-hits {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tours-stops {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.tours-stop-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.tours-stop-move {
display: flex;
gap: 0.25rem;
}
.muted {
color: rgba(201, 169, 110, 0.65);
font-size: 0.85rem;
}
.danger {
color: #ffaaaa !important;
border-color: rgba(255, 170, 170, 0.4) !important;
}
@media (max-width: 900px) {
.tours-layout {
grid-template-columns: 1fr;
}
}
+363
View File
@@ -0,0 +1,363 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, type TourSummary } from '../api/client';
import type { Painting } from '../types';
import './ToursPage.css';
interface Props {
onBack: () => void;
}
interface StopDraft {
paintingId: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
body: string;
}
export default function ToursPage({ onBack }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [stops, setStops] = useState<StopDraft[]>([]);
const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<
Array<{ id: number; title: string; artistName: string; year: number | null; thumbnailPath: string | null }>
>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newTitle, setNewTitle] = useState('');
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listAdminTours();
setTours(data.tours);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadList();
}, [loadList]);
const openTour = async (id: number) => {
setSelectedId(id);
setError(null);
try {
const data = await api.getTour(id);
setTitle(data.tour.title);
setDescription(data.tour.description || '');
setStatus(data.tour.status);
setStops(
data.paintings.map((p: Painting) => ({
paintingId: p.id,
title: p.title,
artistName: p.artist_name || '',
year: p.year ?? null,
thumbnailPath: p.thumbnail_path || null,
body: data.stopBodies[p.id] || '',
})),
);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
if (searchQ.trim().length < 2) {
setSearchHits([]);
return;
}
try {
const data = await api.search(searchQ.trim(), { types: 'painting', limit: 12 });
setSearchHits(
data.results
.filter((r) => r.type === 'painting')
.map((r) => ({
id: r.id,
title: r.title,
artistName: r.artist_name,
year: r.year,
thumbnailPath: r.thumbnail_path,
})),
);
} catch {
setSearchHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [searchQ]);
const createTour = async () => {
const name = newTitle.trim();
if (!name) return;
setSaving(true);
setError(null);
try {
const { tour } = await api.createTour({ title: name, status: 'draft' });
setNewTitle('');
await loadList();
await openTour(tour.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveMeta = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.updateTour(selectedId, {
title: title.trim(),
description,
status,
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveStops = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.saveTourStops(
selectedId,
stops.map((s) => ({ paintingId: s.paintingId, body: s.body })),
);
await api.updateTour(selectedId, {
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const deleteTour = async () => {
if (!selectedId) return;
if (!window.confirm(t('confirmDelete'))) return;
setSaving(true);
try {
await api.deleteTour(selectedId);
setSelectedId(null);
setStops([]);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const addStop = (hit: {
id: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
}) => {
if (stops.some((s) => s.paintingId === hit.id)) return;
setStops((prev) => [
...prev,
{
paintingId: hit.id,
title: hit.title,
artistName: hit.artistName,
year: hit.year,
thumbnailPath: hit.thumbnailPath,
body: '',
},
]);
setSearchQ('');
setSearchHits([]);
};
const moveStop = (index: number, dir: -1 | 1) => {
const next = index + dir;
if (next < 0 || next >= stops.length) return;
setStops((prev) => {
const copy = [...prev];
const tmp = copy[index];
copy[index] = copy[next];
copy[next] = tmp;
return copy;
});
};
return (
<div className="tours-page">
<header className="tours-header">
<button type="button" className="tours-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <div className="tours-error">{error}</div>}
<div className="tours-layout">
<aside className="tours-list-panel">
<div className="tours-create">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('newTourPlaceholder')}
/>
<button type="button" disabled={saving || !newTitle.trim()} onClick={() => void createTour()}>
{t('create')}
</button>
</div>
{loading ? (
<p className="muted">{t('loading')}</p>
) : (
<ul className="tours-list">
{tours.map((tour) => (
<li key={tour.id}>
<button
type="button"
className={selectedId === tour.id ? 'active' : ''}
onClick={() => void openTour(tour.id)}
>
<strong>{tour.title}</strong>
<span className="muted">
{tour.status} · {t('stopCount', { count: tour.stopCount })}
</span>
</button>
</li>
))}
{tours.length === 0 && <li className="muted">{t('noTours')}</li>}
</ul>
)}
</aside>
<section className="tours-editor">
{!selectedId ? (
<p className="muted">{t('selectTour')}</p>
) : (
<>
<div className="tours-meta">
<label>
{t('tourTitle')}
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
{t('tourDescription')}
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
<label>
{t('status')}
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
>
<option value="draft">{t('draft')}</option>
<option value="published">{t('published')}</option>
</select>
</label>
<div className="tours-meta-actions">
<button type="button" disabled={saving} onClick={() => void saveMeta()}>
{t('saveMeta')}
</button>
<button type="button" className="danger" disabled={saving} onClick={() => void deleteTour()}>
{t('delete')}
</button>
</div>
</div>
<div className="tours-stops-toolbar">
<input
type="search"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder={t('searchPainting')}
/>
<button type="button" disabled={saving} onClick={() => void saveStops()}>
{t('saveStops')}
</button>
</div>
{searchHits.length > 0 && (
<ul className="tours-hits">
{searchHits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => addStop(h)}>
{h.artistName} {h.title}
</button>
</li>
))}
</ul>
)}
<ol className="tours-stops">
{stops.map((stop, index) => (
<li key={stop.paintingId}>
<div className="tours-stop-head">
<span>
{index + 1}. {stop.artistName} {stop.title}
{stop.year != null ? ` (${stop.year})` : ''}
</span>
<div className="tours-stop-move">
<button type="button" onClick={() => moveStop(index, -1)} disabled={index === 0}>
</button>
<button
type="button"
onClick={() => moveStop(index, 1)}
disabled={index === stops.length - 1}
>
</button>
<button
type="button"
className="danger"
onClick={() => setStops((prev) => prev.filter((_, i) => i !== index))}
>
{t('remove')}
</button>
</div>
</div>
<textarea
value={stop.body}
onChange={(e) =>
setStops((prev) =>
prev.map((s, i) => (i === index ? { ...s, body: e.target.value } : s)),
)
}
rows={4}
placeholder={t('stopBodyPlaceholder')}
/>
</li>
))}
{stops.length === 0 && <li className="muted">{t('noStops')}</li>}
</ol>
</>
)}
</section>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
.translations-page {
padding: 1rem 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
color: #f5f0e8;
}
.translations-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.translations-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.translations-coverage {
margin-bottom: 1rem;
opacity: 0.9;
}
.translations-toolbar {
margin-bottom: 1rem;
}
.translations-toolbar select {
margin-left: 0.5rem;
}
.translations-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.translations-list {
max-height: 70vh;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.translations-list table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.translations-list th,
.translations-list td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.translations-row-selected {
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.translations-list tbody tr {
cursor: pointer;
}
.translations-editor {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 1rem;
max-height: 70vh;
overflow: auto;
}
.translations-field {
margin-bottom: 1rem;
}
.translations-field textarea {
width: 100%;
margin-top: 0.35rem;
}
.translations-canonical {
font-size: 0.85rem;
opacity: 0.85;
margin: 0.25rem 0;
}
.translations-error {
color: #ffb4b4;
}
@media (max-width: 900px) {
.translations-layout {
grid-template-columns: 1fr;
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, type TranslationDetail, type TranslationWorklistItem } from '../api/client';
import './TranslationsPage.css';
type EntityType = 'artist' | 'painting' | 'movement';
interface Props {
onBack: () => void;
}
export default function TranslationsPage({ onBack }: Props) {
const { t } = useTranslation('translations');
const [entityType, setEntityType] = useState<EntityType>('artist');
const [items, setItems] = useState<TranslationWorklistItem[]>([]);
const [coverage, setCoverage] = useState<Record<string, number> | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<TranslationDetail | null>(null);
const [draftFields, setDraftFields] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cov, list] = await Promise.all([
api.getTranslationCoverage('ru'),
api.getTranslationWorklist(entityType, 'ru'),
]);
setCoverage(cov.coverage);
setItems(list.items);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [entityType, t]);
useEffect(() => {
void loadList();
setSelectedId(null);
setDetail(null);
}, [loadList]);
const openItem = async (entityId: number) => {
setSelectedId(entityId);
setError(null);
try {
const data = await api.getEntityTranslation(entityType, entityId);
setDetail(data);
const ruFields: Record<string, string> = {};
for (const field of data.translatableFields) {
const row = data.translations.find((tr) => tr.locale === 'ru' && tr.field_name === field);
ruFields[field] = row?.value ?? '';
}
setDraftFields(ruFields);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const saveDraft = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.saveEntityTranslation(entityType, selectedId, {
locale: 'ru',
fields: draftFields,
status: 'draft',
});
await api.publishEntityTranslation(entityType, selectedId, 'ru');
await loadList();
await openItem(selectedId);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="translations-page">
<header className="translations-header">
<button type="button" className="translations-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{coverage && (
<div className="translations-coverage">
<strong>{t('coverage')}:</strong>{' '}
{t('artistsBio')}: {coverage.artists_bio_full}/{coverage.artists_total} ·{' '}
{t('paintingsTitle')}: {coverage.paintings_title}/{coverage.paintings_total} ·{' '}
{t('published')}: {coverage.published_count} · {t('draft')}: {coverage.draft_count}
</div>
)}
<div className="translations-toolbar">
<label>
{t('entityType')}
<select value={entityType} onChange={(e) => setEntityType(e.target.value as EntityType)}>
<option value="artist">artist</option>
<option value="painting">painting</option>
<option value="movement">movement</option>
</select>
</label>
</div>
{error && <p className="translations-error">{error}</p>}
{loading && <p>{t('loadFailed')}</p>}
<div className="translations-layout">
<div className="translations-list">
<table>
<thead>
<tr>
<th>ID</th>
<th>{t('canonical')}</th>
<th>{t('status')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.entityId}
className={selectedId === item.entityId ? 'translations-row-selected' : ''}
onClick={() => void openItem(item.entityId)}
>
<td>{item.entityId}</td>
<td>{item.label}</td>
<td>
{item.publishedCount} / {item.draftCount} draft · {item.missingFields.length} missing
</td>
</tr>
))}
</tbody>
</table>
{!loading && items.length === 0 && <p>{t('noRows')}</p>}
</div>
{detail && selectedId && (
<div className="translations-editor">
<h2>{String(detail.canonical.name || detail.canonical.title || selectedId)}</h2>
{detail.translatableFields.map((field) => (
<div key={field} className="translations-field">
<label>{field}</label>
<p className="translations-canonical">
<strong>{t('canonical')}:</strong>{' '}
{String(detail.canonical[field] ?? '')}
</p>
<textarea
rows={field.includes('bio') || field === 'body' ? 8 : 3}
value={draftFields[field] ?? ''}
onChange={(e) => setDraftFields((prev) => ({ ...prev, [field]: e.target.value }))}
placeholder={t('translation')}
/>
</div>
))}
<button type="button" disabled={saving} onClick={() => void saveDraft()}>
{saving ? '…' : t('publish')}
</button>
</div>
)}
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
.users-page {
padding: 1rem 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
color: #f5f0e8;
}
.users-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.users-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.users-error {
color: #f5a5a5;
}
.users-message {
color: #a8d5a2;
}
.users-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.users-list-panel,
.users-editor {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 1rem;
max-height: 80vh;
overflow: auto;
}
.users-list table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.users-list th,
.users-list td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.users-list tbody tr {
cursor: pointer;
}
.users-row-selected {
background: rgba(255, 255, 255, 0.08);
}
.users-create,
.users-editor {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.users-create label,
.users-editor label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.users-create input,
.users-create select,
.users-editor input,
.users-editor select {
padding: 0.4rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
color: inherit;
}
.users-perms {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0;
}
.users-perm-row {
flex-direction: row !important;
align-items: center;
gap: 0.5rem !important;
margin: 0.25rem 0;
}
.users-hint {
opacity: 0.85;
font-size: 0.9rem;
}
.users-meta {
opacity: 0.8;
font-size: 0.85rem;
}
.users-create button,
.users-editor button {
align-self: flex-start;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
}
.users-create button:disabled,
.users-editor button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.users-password-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
@media (max-width: 900px) {
.users-layout {
grid-template-columns: 1fr;
}
}
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
ALL_STAFF_PERMISSIONS,
api,
type StaffPermission,
type StaffUser,
} from '../api/client';
import { useAuth } from '../context/AuthContext';
import './UsersPage.css';
interface Props {
onBack: () => void;
}
function emptyCreateForm() {
return {
username: '',
password: '',
role: 'curator' as 'admin' | 'curator',
permissions: ['images', 'checkup', 'curator_notes'] as StaffPermission[],
};
}
export default function UsersPage({ onBack }: Props) {
const { t } = useTranslation('users');
const { isAdmin, username: selfUsername } = useAuth();
const [users, setUsers] = useState<StaffUser[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editRole, setEditRole] = useState<'admin' | 'curator'>('curator');
const [editPermissions, setEditPermissions] = useState<StaffPermission[]>([]);
const [editActive, setEditActive] = useState(true);
const [newPassword, setNewPassword] = useState('');
const selected = users.find((u) => u.id === selectedId) ?? null;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listUsers();
setUsers(data.users);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!selected) return;
setEditRole(selected.role);
setEditPermissions(selected.permissions);
setEditActive(selected.is_active);
setNewPassword('');
setMessage(null);
}, [selected]);
const toggleCreatePerm = (perm: StaffPermission) => {
setCreateForm((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has
? prev.permissions.filter((p) => p !== perm)
: [...prev.permissions, perm],
};
});
};
const toggleEditPerm = (perm: StaffPermission) => {
setEditPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const { user } = await api.createUser({
username: createForm.username.trim(),
password: createForm.password,
role: createForm.role,
permissions: createForm.role === 'admin' ? ALL_STAFF_PERMISSIONS : createForm.permissions,
});
setCreateForm(emptyCreateForm());
setMessage(t('created'));
await load();
setSelectedId(user.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (!selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.updateUser(selected.id, {
role: editRole,
permissions: editRole === 'admin' ? ALL_STAFF_PERMISSIONS : editPermissions,
is_active: editActive,
});
setMessage(t('saved'));
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleResetPassword = async () => {
if (!selected || !newPassword) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.resetUserPassword(selected.id, newPassword);
setNewPassword('');
setMessage(t('passwordReset'));
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="users-page">
<header className="users-header">
<button type="button" className="users-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <p className="users-error">{error}</p>}
{message && <p className="users-message">{message}</p>}
<div className="users-layout">
<section className="users-list-panel">
{loading ? (
<p>{t('loading')}</p>
) : (
<div className="users-list">
<table>
<thead>
<tr>
<th>{t('username')}</th>
<th>{t('role')}</th>
<th>{t('active')}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className={user.id === selectedId ? 'users-row-selected' : undefined}
onClick={() => setSelectedId(user.id)}
>
<td>
{user.username}
{user.username === selfUsername ? ' *' : ''}
</td>
<td>{user.role === 'admin' ? t('roleAdmin') : t('roleCurator')}</td>
<td>{user.is_active ? t('active') : t('inactive')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<form className="users-create" onSubmit={handleCreate}>
<h2>{t('createTitle')}</h2>
<label>
{t('username')}
<input
value={createForm.username}
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
autoComplete="off"
required
minLength={2}
maxLength={64}
/>
</label>
<label>
{t('password')}
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label>
{t('role')}
<select
value={createForm.role}
onChange={(e) =>
setCreateForm((p) => ({
...p,
role: e.target.value as 'admin' | 'curator',
}))
}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{createForm.role === 'curator' && (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={createForm.permissions.includes(perm)}
onChange={() => toggleCreatePerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
)}
{createForm.role === 'admin' && <p className="users-hint">{t('adminAllPerms')}</p>}
<button type="submit" disabled={saving}>
{saving ? t('creating') : t('create')}
</button>
</form>
</section>
<section className="users-editor">
{!selected ? (
<p className="users-hint">{t('selectUser')}</p>
) : (
<>
<h2>{selected.username}</h2>
<p className="users-meta">
{t('lastLogin')}:{' '}
{selected.last_login_at
? new Date(selected.last_login_at).toLocaleString()
: t('never')}
</p>
<label>
{t('role')}
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as 'admin' | 'curator')}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{editRole === 'curator' ? (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={editPermissions.includes(perm)}
onChange={() => toggleEditPerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
) : (
<p className="users-hint">{t('adminAllPerms')}</p>
)}
<label className="users-perm-row">
<input
type="checkbox"
checked={editActive}
onChange={(e) => setEditActive(e.target.checked)}
/>
{editActive ? t('active') : t('inactive')}
</label>
<button type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? t('saving') : t('save')}
</button>
<div className="users-password-block">
<h3>{t('resetPassword')}</h3>
<label>
{t('newPassword')}
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</label>
<button
type="button"
onClick={() => void handleResetPassword()}
disabled={saving || newPassword.length < 8}
>
{t('resetPassword')}
</button>
</div>
</>
)}
</section>
</div>
</div>
);
}
+82
View File
@@ -20,6 +20,8 @@ export interface ArtMovement {
era_name?: string;
description: string;
color: string;
/** Influence edges on paintings by artists in this movement (timeline band weight). */
influence_link_count?: number;
}
export interface Artist {
@@ -32,6 +34,8 @@ export interface Artist {
movement_color?: string;
portrait_path: string | null;
portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
bio_short?: string;
bio_full?: string;
wikipedia_title: string;
@@ -58,8 +62,11 @@ export interface Painting {
year: number;
year_end?: number;
description: string;
curator_notes?: string;
image_path: string | null;
thumbnail_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
wikipedia_title: string;
sort_order: number;
artist_name?: string;
@@ -129,6 +136,38 @@ export interface MovementGalleryDetail {
paintings: Painting[];
}
/** Artist row for movement gallery entry filter (portraits + painting counts). */
export interface ArtistSummary {
id: number;
name: string;
birth_year: number | null;
death_year: number | null;
portrait_path: string | null;
portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
painting_count: number;
}
export interface TourSummary {
id: number;
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
coverThumbnailPath: string | null;
coverImagePath: string | null;
stopCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface TourGalleryDetail {
tour: TourSummary;
paintings: Painting[];
stopBodies: Record<number, string>;
}
export interface TimelineData {
eras: HistoricalEra[];
movements: ArtMovement[];
@@ -139,6 +178,49 @@ export interface CatalogBootstrap extends TimelineData {
artists: Artist[];
}
export interface CatalogSearchArtistResult {
type: 'artist';
id: number;
name: string;
birth_year: number | null;
death_year: number | null;
movement_name: string | null;
portrait_path: string | null;
portrait_thumb_path: string | null;
}
export interface CatalogSearchMovementResult {
type: 'movement';
id: number;
name: string;
color: string;
start_year: number;
end_year: number;
era_name: string | null;
}
export interface CatalogSearchPaintingResult {
type: 'painting';
id: number;
title: string;
year: number | null;
artist_id: number;
artist_name: string;
movement_name: string | null;
thumbnail_path: string | null;
image_path: string | null;
}
export type CatalogSearchResult =
| CatalogSearchArtistResult
| CatalogSearchMovementResult
| CatalogSearchPaintingResult;
export interface CatalogSearchResponse {
q: string;
results: CatalogSearchResult[];
}
export interface YearBounds {
min_year: number;
max_year: number;
+263 -12
View File
@@ -8,6 +8,9 @@ export type SurfaceTextureKind =
| 'marble-veined-carrara'
| 'marble-veined-emerald'
| 'marble-checker'
| 'marble-opus-sectile'
| 'marble-revetment'
| 'coffered-wood'
| 'limestone'
| 'sandstone'
| 'rough-stone'
@@ -244,6 +247,43 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
noiseOverlay(ctx, size, 0.035, seed + 1);
}
/** Veined marble confined to one slab rect — for revetment panels and inlay. */
function marbleSlab(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
base: string,
vein: string,
seed: number
) {
const rand = seeded(seed);
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.fillStyle = base;
ctx.fillRect(x, y, w, h);
const [vr, vg, vb] = hexToRgb(vein);
const strands = 10 + Math.floor(w / 24);
for (let i = 0; i < strands; i++) {
ctx.strokeStyle = `rgba(${vr},${vg},${vb},${0.1 + rand() * 0.3})`;
ctx.lineWidth = 0.6 + rand() * 3.4;
ctx.beginPath();
let px = x + rand() * w;
let py = y + rand() * h;
ctx.moveTo(px, py);
for (let s = 0; s < 9; s++) {
px += (rand() - 0.35) * w * 0.28;
py += (rand() - 0.5) * h * 0.22;
ctx.quadraticCurveTo(px - w * 0.05, py + h * 0.04, px, py);
}
ctx.stroke();
}
ctx.restore();
}
function woodPanelSurface(
ctx: CanvasRenderingContext2D,
size: number,
@@ -346,6 +386,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.03, 103);
break;
}
case 'marble-opus-sectile': {
// Cosmatesque paving: cut-stone squares with diagonally-set inlays and
// corner triangles in porphyry, verd-antique, and giallo over travertine.
const stones = [
['#6a3c42', '#4c2830'],
['#47554b', '#333f38'],
['#8a7752', '#66573c'],
['#4e4a45', '#363330'],
];
fill(ctx, size, '#7d6e58');
const grid = 2;
const cell = size / grid;
for (let row = 0; row < grid; row++) {
for (let col = 0; col < grid; col++) {
const x = col * cell;
const y = row * cell;
const rand = seeded(610 + row * grid + col);
// Travertine ground slab for this bay
marbleSlab(ctx, x, y, cell, cell, '#8f7f66', '#6d5f4c', 620 + row * grid + col);
const pick = stones[(row * 3 + col * 5 + Math.floor(rand() * 2)) % stones.length];
const inset = cell * 0.045;
// Corner triangles
ctx.fillStyle = pick[1];
const corners: [number, number, number, number, number, number][] = [
[x + inset, y + inset, x + cell * 0.5, y + inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + inset, x + cell * 0.5, y + inset, x + cell - inset, y + cell * 0.5],
[x + inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + cell - inset, y + cell * 0.5],
];
for (const [ax, ay, bx, by, cx2, cy2] of corners) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.lineTo(cx2, cy2);
ctx.closePath();
ctx.fill();
}
// Diamond inlay set on the diagonal
const cx = x + cell / 2;
const cy = y + cell / 2;
const r = cell * 0.34;
ctx.save();
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.clip();
marbleSlab(ctx, cx - r, cy - r, r * 2, r * 2, pick[0], pick[1], 640 + row * grid + col);
ctx.restore();
ctx.strokeStyle = 'rgba(40,30,22,0.45)';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.stroke();
// Cut joint around the bay
ctx.strokeStyle = 'rgba(40,30,22,0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
noiseOverlay(ctx, size, 0.03, 601);
break;
}
case 'marble-revetment': {
// Basilica wall cladding: book-matched marble panels in carved stone
// surrounds — Santa Sabina / San Vitale, no mosaic.
fill(ctx, size, '#a8977c');
const cols = 2;
const rows = 2;
const pw = size / cols;
const ph = size / rows;
const fields = [
['#5e6d64', '#33423a'],
['#8a7a5e', '#5a4c36'],
['#5e6d64', '#33423a'],
['#6f5f56', '#453a34'],
];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * pw;
const y = row * ph;
// Travertine surround
marbleSlab(ctx, x, y, pw, ph, '#b0a084', '#8a7658', 660 + row * cols + col);
const m = pw * 0.09;
const iw = pw - m * 2;
const ih = ph - m * 2;
const field = fields[(row * cols + col) % fields.length];
// Book-matched pair: the same slab mirrored about the panel centre
const half = iw / 2;
marbleSlab(ctx, x + m, y + m, half, ih, field[0], field[1], 670 + row * cols + col);
ctx.save();
ctx.translate(x + m + iw, y + m);
ctx.scale(-1, 1);
ctx.drawImage(ctx.canvas, x + m, y + m, half, ih, 0, 0, half, ih);
ctx.restore();
// Moulded frame around the panel
ctx.strokeStyle = 'rgba(60,44,30,0.55)';
ctx.lineWidth = 3;
ctx.strokeRect(x + m, y + m, iw, ih);
ctx.strokeStyle = 'rgba(255,244,222,0.35)';
ctx.lineWidth = 2;
ctx.strokeRect(x + m - 4, y + m - 4, iw + 8, ih + 8);
}
}
// Horizontal string course between panel registers
ctx.fillStyle = 'rgba(90,68,44,0.35)';
ctx.fillRect(0, ph - 3, size, 6);
noiseOverlay(ctx, size, 0.04, 680);
break;
}
case 'coffered-wood': {
// Open-timber coffered basilica ceiling: recessed dark walnut boxes with
// lit chamfers so the grid reads as depth, not a flat pattern.
fill(ctx, size, '#2e1e12');
const n = 6;
const cell = size / n;
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
const x = col * cell;
const y = row * cell;
const beam = cell * 0.14;
// Beam faces catch light from below
ctx.fillStyle = '#6a4a2c';
ctx.fillRect(x, y, cell, beam);
ctx.fillRect(x, y, beam, cell);
ctx.fillStyle = '#4a3018';
ctx.fillRect(x + cell - beam, y, beam, cell);
ctx.fillRect(x, y + cell - beam, cell, beam);
// Recessed coffer pan
const px = x + beam;
const py = y + beam;
const pwid = cell - beam * 2;
const phei = cell - beam * 2;
const grad = ctx.createLinearGradient(px, py, px, py + phei);
grad.addColorStop(0, '#22150c');
grad.addColorStop(1, '#3c2616');
ctx.fillStyle = grad;
ctx.fillRect(px, py, pwid, phei);
// Central gilt rosette boss
ctx.fillStyle = '#8a6a2a';
ctx.beginPath();
ctx.arc(px + pwid / 2, py + phei / 2, Math.min(pwid, phei) * 0.13, 0, Math.PI * 2);
ctx.fill();
}
}
noiseOverlay(ctx, size, 0.05, 690);
break;
}
case 'limestone':
fill(ctx, size, '#ddd4c4');
for (let y = 0; y < size; y += 4) {
@@ -460,15 +655,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
}
break;
case 'mosaic-byzantine': {
fill(ctx, size, '#1a1814');
const cell = size / 32;
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
// Gold-ground tessera field (gold-leaf glass, individually jittered) with
// sparse inlaid accent tesserae and a meander border course — Hagia Sophia /
// Ravenna style, not a flat multicolor checker.
fill(ctx, size, '#1a1408');
const grid = 32;
const cell = size / grid;
const rand = seeded(777);
const accents: [number, number, number][] = [
[32, 52, 98],
[126, 30, 44],
[28, 78, 58],
[18, 16, 22],
];
for (let gy = 0; gy < grid; gy++) {
for (let gx = 0; gx < grid; gx++) {
const x = gx * cell;
const y = gy * cell;
const roll = rand();
let r: number, g: number, b: number;
if (roll < 0.05) {
[r, g, b] = accents[Math.floor(rand() * accents.length)];
} else {
const j = (rand() - 0.5) * 50;
r = 214 + j;
g = 170 + j * 0.72;
b = 58 + j * 0.35;
}
ctx.fillStyle = rgb(r, g, b);
const pad = 1 + rand() * 1.4;
ctx.fillRect(x + pad, y + pad, cell - pad * 2, cell - pad * 2);
}
}
// Dark grout between tesserae reads as individual glass/gold tiles.
ctx.strokeStyle = 'rgba(16,10,4,0.4)';
ctx.lineWidth = 1;
for (let i = 0; i <= grid; i++) {
ctx.beginPath();
ctx.moveTo(i * cell, 0);
ctx.lineTo(i * cell, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * cell);
ctx.lineTo(size, i * cell);
ctx.stroke();
}
// Meander (key-pattern) register band dividing the panel, in navy and gold.
const bandRow = Math.floor(grid * 0.47);
for (let gx = 0; gx < grid; gx++) {
const on = Math.floor(gx / 2) % 2 === 0;
ctx.fillStyle = on ? '#0f2a52' : '#e0b840';
ctx.fillRect(gx * cell, bandRow * cell, cell, cell * 0.5);
}
noiseOverlay(ctx, size, 0.045, 778);
break;
}
case 'mosaic-roman': {
@@ -583,7 +822,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out;
}
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
@@ -591,8 +830,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 16;
tex.colorSpace = colorSpace;
tex.anisotropy = 8;
return tex;
}
@@ -611,6 +850,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-veined-carrara': 0.16,
'marble-veined-emerald': 0.18,
'marble-checker': 0.14,
'marble-opus-sectile': 0.2,
'marble-revetment': 0.34,
'coffered-wood': 0.72,
'gilded-stucco': 0.22,
'velvet-crimson': 0.92,
'velvet-navy': 0.92,
@@ -638,6 +880,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.08,
'marble-veined-carrara': 0.1,
'marble-opus-sectile': 0.14,
'marble-revetment': 0.1,
'gilded-stucco': 0.65,
'concrete-polished': 0.12,
'terrazzo': 0.15,
@@ -647,6 +891,9 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4,
'marble-opus-sectile': 5.0,
'marble-revetment': 4.4,
'coffered-wood': 4.2,
flagstone: 3.0,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
@@ -669,6 +916,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'painted-lime': 0.62,
'marble-white': 0.75,
'marble-veined-carrara': 0.8,
'marble-opus-sectile': 0.55,
'marble-revetment': 0.6,
'coffered-wood': 1.0,
'velvet-crimson': 0.5,
'velvet-navy': 0.5,
'velvet-emerald': 0.5,
@@ -686,8 +936,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
normalMap: imageDataToTexture(normalData),
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
+29
View File
@@ -0,0 +1,29 @@
export const LOCALE_STORAGE_KEY = 'gallery_locale';
export const SUPPORTED_LOCALES = ['en', 'ru'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export function readStoredLocale(): AppLocale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === 'ru' || stored === 'en') return stored;
} catch {
/* ignore */
}
const nav = typeof navigator !== 'undefined' ? navigator.language : 'en';
return nav.toLowerCase().startsWith('ru') ? 'ru' : 'en';
}
export function writeStoredLocale(locale: AppLocale) {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
document.documentElement.lang = locale;
}
export function localeQuery(locale: AppLocale): string {
return locale === 'en' ? '' : `locale=${encodeURIComponent(locale)}`;
}
export function withLocale(url: string, locale: AppLocale): string {
if (locale === 'en') return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}locale=${encodeURIComponent(locale)}`;
}
+147 -29
View File
@@ -1,6 +1,5 @@
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import { comparePaintingsChronological } from './paintingUtils';
/** Target capacity per movement wing (5060 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -30,6 +29,8 @@ export interface MovementHallLayout {
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
/** When true, far (Z) wall has a center exit; paintings hang on flanks only. */
endWallHasDoor: boolean;
}
const WALL_HEIGHT = 4.2;
@@ -44,6 +45,9 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4;
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
const DOOR_WIDTH = 2.4;
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
@@ -92,10 +96,6 @@ function layoutRow(paintings: Painting[], span: number) {
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function orderForWall(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function formatYearLabel(paintings: Painting[]) {
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
if (years.length === 0) return 'Undated works';
@@ -104,22 +104,40 @@ function formatYearLabel(paintings: Painting[]) {
return min === max ? `${min}` : `${min} ${max}`;
}
/** Preserve caller order (chrono for movements, stop order for tours). */
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
const sorted = [...paintings].sort(comparePaintingsChronological);
if (sorted.length === 0) return [[]];
if (paintings.length === 0) return [[]];
const chunks: Painting[][] = [];
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
}
return chunks;
}
function distributeToSideWalls(paintings: Painting[]) {
const left: Painting[] = [];
const right: Painting[] = [];
const sorted = [...paintings].sort(comparePaintingsChronological);
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
return { left: orderForWall(left), right: orderForWall(right) };
/**
* U-shaped visit order: left far/end wall right.
* Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right.
*/
function distributeAcrossWalls(paintings: Painting[]) {
const n = paintings.length;
if (n < 3) {
const mid = Math.ceil(n / 2);
return {
back: [] as Painting[],
left: paintings.slice(0, mid),
right: paintings.slice(mid),
};
}
const q = Math.floor(n / 3);
const r = n % 3;
const leftCount = q + (r > 0 ? 1 : 0);
const backCount = q + (r > 1 ? 1 : 0);
return {
left: paintings.slice(0, leftCount),
back: paintings.slice(leftCount, leftCount + backCount),
right: paintings.slice(leftCount + backCount),
};
}
function layoutSideSlots(
@@ -132,16 +150,76 @@ function layoutSideSlots(
if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
}));
return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
};
});
}
/** Far wall ahead of the entrance — full span, or split across door flanks. */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number,
hasDoor: boolean
): FrameSlot[] {
if (paintings.length === 0) return [];
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
if (!hasDoor) {
const { slots: rowSlots } = layoutRow(paintings, width);
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [s.offset, y, z] as [number, number, number],
}));
}
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
return rowSlots.map((s) => {
let x = centerX + s.offset;
const halfOuter = frameOuterW(s.maxW, false) / 2;
if (side === 'left' && x + halfOuter > -doorEdge) {
x = -doorEdge - halfOuter;
} else if (side === 'right' && x - halfOuter < doorEdge) {
x = doorEdge + halfOuter;
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [x, y, z] as [number, number, number],
};
});
};
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
}
export function buildMovementHallLayout(
@@ -149,16 +227,36 @@ export function buildMovementHallLayout(
hallIndex: number,
hallCount: number
): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings);
const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH;
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit.
const endWallHasDoor = hallCount > 1;
let width: number;
if (endWallHasDoor) {
const mid = Math.ceil(back.length / 2);
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
} else {
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
}
const halfW = width / 2;
const halfD = depth / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] },
{
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
},
{
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
@@ -181,6 +279,7 @@ export function buildMovementHallLayout(
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
endWallHasDoor,
};
}
@@ -227,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, '
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
}
/** Place windows on side walls only, in gaps between painting frames. */
/** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
export function computeSideWallWindows(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
@@ -265,6 +364,25 @@ export function computeSideWallWindows(
lightIntensity: tmpl.lightIntensity,
});
}
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
if (!specs.some((s) => s.wall === side)) {
const span = Math.max(2.4, halfD * 1.2);
const count = span > 8 ? 2 : 1;
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
specs.push({
wall: side,
x: halfD * t,
y: 3.45,
width: Math.min(tmpl.width, 1.15),
height: Math.min(tmpl.height, 1.05),
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity * 0.85,
});
}
}
}
return specs;
+7
View File
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
const flag = painting.has_influence_links;
return flag === true || flag === 't' || flag === 'true' || flag === 1;
}
/** True when the painting has public curator notes. */
export function paintingHasCuratorNotes(
painting: Pick<Painting, 'curator_notes'>
): boolean {
return Boolean(painting.curator_notes?.trim());
}
+1
View File
@@ -12,6 +12,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 219 KiB

Some files were not shown because too many files have changed in this diff Show More