Compare commits
79
Commits
b4425445bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f | ||
|
|
971c1e8dd8 | ||
|
|
4088d7d57b | ||
|
|
f20f811f21 | ||
|
|
cfee69c9a6 | ||
|
|
8a68e98258 | ||
|
|
8c823a6dfe | ||
|
|
dc0ac81081 | ||
|
|
f9b0fb0496 | ||
|
|
1cb9eef935 | ||
|
|
1853222e01 | ||
|
|
710d262516 | ||
|
|
0a5918c4dd | ||
|
|
ad1572b3aa | ||
|
|
8111cd0163 | ||
|
|
23e1210e86 | ||
|
|
485c7d3e6f | ||
|
|
08ff4651e2 | ||
|
|
0466b77328 | ||
|
|
bfa21989c9 | ||
|
|
67594ea4d3 | ||
|
|
41d2d5d844 | ||
|
|
8d3ebcce60 | ||
|
|
89e39d408a | ||
|
|
6656f91f25 | ||
|
|
8005252881 | ||
|
|
d709e98640 | ||
|
|
8e444fa461 | ||
|
|
f6c73c1792 | ||
|
|
bc8369e373 | ||
|
|
77befd94b2 | ||
|
|
cc39c2b138 | ||
|
|
5ddc3fd7f0 | ||
|
|
48bd17e985 | ||
|
|
62d7ebbe6a | ||
|
|
a2c27ea526 | ||
|
|
ca58c43648 | ||
|
|
f247b418d8 | ||
|
|
81ebad2120 | ||
|
|
acc4a91a08 | ||
|
|
50fc253ab2 | ||
|
|
f78c14f307 | ||
|
|
62096e8210 | ||
|
|
0ceb1c1c8c | ||
|
|
f72ddcc3e4 | ||
|
|
3810e61bb8 | ||
|
|
cdca271e08 | ||
|
|
aa125ccb9b | ||
|
|
0de95fd151 | ||
|
|
c391fcc21e | ||
|
|
3934cedc62 | ||
|
|
9e28f3db3f | ||
|
|
4361178413 | ||
|
|
ae6ffc8a08 | ||
|
|
aa6635803d | ||
|
|
21e3e41e48 | ||
|
|
313666a4ab | ||
|
|
ac1ae5794f | ||
|
|
a86fdcce1b | ||
|
|
96285737f5 | ||
|
|
a2263ca186 | ||
|
|
1408811948 | ||
|
|
99b8559607 | ||
|
|
bdddadc4d6 | ||
|
|
9da065acbe | ||
|
|
aa31a2aa6e | ||
|
|
2edf577faf | ||
|
|
02d238b043 | ||
|
|
7f26696328 | ||
|
|
2b4274093e | ||
|
|
f542c689c9 | ||
|
|
b2cae284ac | ||
|
|
4eead54062 | ||
|
|
df29848d89 | ||
|
|
0972b5df99 |
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Default to dev for all changes; prod only when explicitly requested
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Development-first workflow
|
||||
|
||||
**Default target is always development** unless the user explicitly names **prod** / **production**.
|
||||
|
||||
## What "dev" means
|
||||
|
||||
| Layer | Dev default |
|
||||
|-------|-------------|
|
||||
| Code | Edit in this repo; run `npm run dev:web` on the dev PC |
|
||||
| Database | `gallery_dev` via root `.env` (`DB_NAME=gallery_dev`) |
|
||||
| Data / images | `data/images/` in the repo |
|
||||
| Public URL | https://devgallery.mysuperlab.netcraze.pro |
|
||||
| Migrations / seeds / scripts | `npm run dev:migrate`, `npm run dev:seed`, etc. against **dev** |
|
||||
|
||||
## Production is scheduled, not daily
|
||||
|
||||
Prod (`gallery_prod`, TrueNAS Docker, https://gallery.mysuperlab.netcraze.pro) is updated **roughly weekly** (or when the user explicitly asks for prod):
|
||||
|
||||
1. Validate on dev
|
||||
2. `npm run dev:db:backup` → `npm run devtoprod:db:restore` (if DB/data changed)
|
||||
3. `npm run devtoprod:images` (if images changed)
|
||||
4. `npm run prod:docker:publish` (if code changed)
|
||||
5. Restart **gallery-web** on TrueNAS
|
||||
|
||||
Do **not** edit `infra/docker/.env.prod`, run `db:restore:prod`, `images:sync-to-prod`, or `docker:publish` unless the user clearly targets prod.
|
||||
|
||||
## When the user says "fix", "change", or "modify"
|
||||
|
||||
- Assume **dev** code, **dev** DB, and **dev** data paths
|
||||
- Test against devgallery or localhost
|
||||
- Mention prod steps only as optional follow-up for the next release
|
||||
|
||||
## When the user says "prod" or "production"
|
||||
|
||||
- Use `gallery_prod`, `infra/docker/.env.prod`, TrueNAS compose, and prod deploy docs
|
||||
- Require explicit confirmation before destructive prod operations (`db:restore:prod`, etc.)
|
||||
|
||||
Full operator guide: `Documentation/environments.md`
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Gallery UI interaction and component standards for client screens
|
||||
globs: client/src/**/*.{tsx,css}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# UI interaction standards
|
||||
|
||||
Follow [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) (MUST / SHOULD / MAY). Do not invent a second nav, toast, or CSS framework.
|
||||
|
||||
## MUST
|
||||
|
||||
- Drill-down via `HomePage` `View` state. Nested screens get an explicit Back; do not rely on the browser Back button.
|
||||
- **Back to Timeline** → `goToTimelineHome()` (clears hall, resets year range). **Back to Gallery** → same hall session (camera/wing kept).
|
||||
- Hide actions the user cannot `can()`; do not leave buttons that 403.
|
||||
- Visitor chrome strings in `locales/{en,ru}`. Movement colours via `utils/movementColor.ts`. Timeline zoom/pan via `utils/timelineView.ts`.
|
||||
- Loading: `GalleryLoadingMarker`. Errors/empty states: visible copy, not a blank canvas.
|
||||
- Modals: `role="dialog"`, visible close, Escape. No nested modals. Destructive actions confirm first.
|
||||
- Colocated CSS; museum gold `#c9a96e` / navy `/ Georgia`. No MUI/Ant Design.
|
||||
|
||||
## SHOULD
|
||||
|
||||
- Reuse `CatalogSearchBar`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Curator filters above the table; expensive work behind an explicit button.
|
||||
- Update the standards doc when changing Back, search, layout query, or loading behaviour.
|
||||
+22
-3
@@ -1,11 +1,30 @@
|
||||
# Development only — copy to .env (not committed). All day-to-day work uses gallery_dev.
|
||||
# Promote to prod on a scheduled release (~weekly); see Documentation/environments.md
|
||||
DB_HOST=192.168.10.122
|
||||
DB_PORT=5432
|
||||
DB_USER=gallery
|
||||
DB_PASSWORD=
|
||||
DB_NAME=Gallery
|
||||
PORT=3001
|
||||
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
|
||||
DB_NAME=gallery_dev
|
||||
|
||||
# Public dev URL (Keenetic → 192.168.10.70:5173)
|
||||
# Local-only coding: npm run dev:server + dev:client on 3520 / 5173 instead
|
||||
PORT=3451
|
||||
HOST=0.0.0.0
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
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
|
||||
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via proxy → Secure cookie). Set true/false to force.
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=
|
||||
|
||||
# Production uses infra/docker/.env.prod → gallery_prod at gallery.mysuperlab.netcraze.pro:5173
|
||||
# See Documentation/environments.md
|
||||
|
||||
# Optional — enable extra museum search in fetch-missing-images / search-missing-paintings
|
||||
# SMITHSONIAN_API_KEY=
|
||||
# HARVARD_ART_API_KEY=
|
||||
|
||||
+17
@@ -4,10 +4,27 @@ client/node_modules/
|
||||
.env
|
||||
.env.local
|
||||
.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
|
||||
|
||||
+673
-19
@@ -1,14 +1,180 @@
|
||||
# Art Gallery — REST API
|
||||
|
||||
Base URL in development:
|
||||
Base URL (paths are the same on every host; only the origin changes):
|
||||
|
||||
- **Direct:** `http://localhost:3001`
|
||||
- **Via Vite proxy:** `http://localhost:5173` (same paths)
|
||||
| Context | Base URL |
|
||||
|---------|----------|
|
||||
| **Production (public)** | `https://gallery.mysuperlab.netcraze.pro` |
|
||||
| **Production (LAN)** | `http://192.168.10.122:5173` |
|
||||
| **Development (public)** | `https://devgallery.mysuperlab.netcraze.pro` |
|
||||
| **Development (LAN)** | `http://192.168.10.70:5173` |
|
||||
| **Local Vite proxy** | `http://localhost:5173` (proxies `/api` and `/images` to API on `:3451`) |
|
||||
| **Local API only** | `http://localhost:3451` (when using `npm run dev:web`) |
|
||||
|
||||
See [environments.md](environments.md) for Keenetic rules, databases, and deploy.
|
||||
|
||||
All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status.
|
||||
|
||||
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 file’s `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
|
||||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||||
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
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.
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
**Response (anonymous)**
|
||||
|
||||
```json
|
||||
{ "role": "user" }
|
||||
```
|
||||
|
||||
**Response (staff session)**
|
||||
|
||||
```json
|
||||
{
|
||||
"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:** same shape as `/me` for staff (`role`, `username`, `permissions`).
|
||||
|
||||
**Errors:** `401` invalid credentials or disabled account, `400` missing fields.
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Destroys the session cookie.
|
||||
|
||||
**Response:** `{ "ok": true }`
|
||||
|
||||
### Permission flags
|
||||
|
||||
| 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) |
|
||||
|
||||
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. Admin-only routes (no matching permission flag) → **`403`** `{ "error": "Admin access required" }`.
|
||||
|
||||
### Users (admin / `users` permission)
|
||||
|
||||
| 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 user’s sessions |
|
||||
|
||||
Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`.
|
||||
|
||||
### Audit log (admin)
|
||||
|
||||
Admin-only reports over `curator_audit_log`. Responses include `database` (`process.env.DB_NAME`) so the UI shows whether you are reading **dev** or **prod**.
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| `GET` | `/api/audit` | Paginated entries + resource labels. Query: `user_id`, `username`, `action`, `resource_type`, `resource_id`, `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `GET` | `/api/audit/summary` | Totals (all / 24h / 7d), breakdowns by user / action / resource_type. Same filters as list |
|
||||
| `GET` | `/api/audit/meta` | Distinct users, actions, resource types for filter dropdowns |
|
||||
|
||||
Each list entry includes `created_at`, `username`, `user_role`, `action`, `resource_type`, `resource_id`, `resource_label`, `details`, `ip_address`.
|
||||
|
||||
### 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.*` |
|
||||
| `/api/audit/*` | admin role | — (read) |
|
||||
|
||||
**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)). Browse via admin **Activity** UI or `/api/audit`.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/catalog/bootstrap`
|
||||
|
||||
**Preferred for timeline first paint.** Returns bounds, eras, movements, and slim artist rows in a single response (replaces the separate `bounds` + `timeline` + `artists?timeline=1` waterfall).
|
||||
|
||||
**Query**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `start` | int | bounds `min_year` | Window start year |
|
||||
| `end` | int | bounds `max_year` | Window end year |
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"bounds": { "min_year": -800, "max_year": 2100 },
|
||||
"eras": [ … ],
|
||||
"movements": [ … ],
|
||||
"artists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Claude Monet",
|
||||
"birth_year": 1840,
|
||||
"death_year": 1926,
|
||||
"movement_id": 12,
|
||||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||||
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg",
|
||||
"wikipedia_title": "Claude Monet",
|
||||
"century": 19,
|
||||
"movement_name": "Impressionism",
|
||||
"movement_color": "#6B8E9F"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Caching:** `Cache-Control: public, max-age=300` with `ETag` (304 when catalog row counts unchanged).
|
||||
|
||||
The React home page loads this endpoint **once** on mount. Pan and zoom filter movements and portraits **client-side** — no refetch per view change.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/bounds`
|
||||
@@ -52,6 +218,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.
|
||||
@@ -63,9 +370,12 @@ Artists for timeline portraits and the movement flow diagram.
|
||||
| `start` | int | Only artists alive after this year |
|
||||
| `end` | int | Only artists born before this year |
|
||||
| `movement_id` | int | Filter by movement |
|
||||
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_short`, `bio_full`; includes `portrait_thumb_path`) |
|
||||
|
||||
**Response** — array of artist objects with joined `movement_name` and `movement_color`.
|
||||
|
||||
The React home page loads the timeline catalog **once** on mount via `GET /api/catalog/bootstrap` (or legacy: `GET /api/bounds` + `GET /api/timeline` + `GET /api/artists?timeline=1`). Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/movements/:id/artists`
|
||||
@@ -76,6 +386,83 @@ 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, after the artist-filter modal).
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"movement": {
|
||||
"id": 34,
|
||||
"name": "Baroque",
|
||||
"start_year": 1600,
|
||||
"end_year": 1750,
|
||||
"era_id": 6,
|
||||
"era_name": "Baroque",
|
||||
"color": "#8B4513",
|
||||
...
|
||||
},
|
||||
"paintings": [
|
||||
{
|
||||
"id": 120,
|
||||
"title": "...",
|
||||
"year": 1640,
|
||||
"artist_id": 5,
|
||||
"artist_name": "Rembrandt",
|
||||
"image_path": "paintings/...",
|
||||
"thumbnail_path": "paintings/thumbs/...",
|
||||
"checkup_checked": false,
|
||||
"checkup_fixed": false,
|
||||
"has_influence_links": true,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `movement.era_name` | Joined from `historical_eras` — used to pick period interior styling |
|
||||
| `paintings[].artist_name` | Artist display name for frame captions (`year · artist`) |
|
||||
| `paintings` order | Chronological: `year`, then `sort_order`, artist birth year, title |
|
||||
|
||||
Includes all paintings whose `artist.movement_id` matches `:id`. Returns **404** if the movement does not exist.
|
||||
|
||||
**Client:** `api.getMovementGallery(id)` in `client/src/api/client.ts`; rendered by `VirtualGallery` in `mode: 'movement'`.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists/:id`
|
||||
|
||||
Full artist profile for the bio page and 3D gallery entry.
|
||||
@@ -93,7 +480,10 @@ Full artist profile for the bio page and 3D gallery entry.
|
||||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||||
"bio_short": "First two sentences from Wikipedia…",
|
||||
"bio_full": "Full Wikipedia lead section…",
|
||||
"wikipedia_title": "Claude Monet"
|
||||
"wikipedia_title": "Claude Monet",
|
||||
"checkup_checked": false,
|
||||
"checkup_fixed": false,
|
||||
"palette_metadata": { "nationality": "French", "styles": "Impressionism, …", "source": "PainterPalette.csv", ... }
|
||||
},
|
||||
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
|
||||
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ]
|
||||
@@ -104,22 +494,120 @@ Each painting includes:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp on a shared rail (40 cm above the tallest frame in the hall) |
|
||||
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||||
| `checkup_fixed` | Image replaced via **Fix it** |
|
||||
|
||||
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
|
||||
Populate biographies with `npm run dev:fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
|
||||
|
||||
Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). After `npm run dev:import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run dev:migrate:artist-checkup-flags` and `npm run dev:migrate:artist-palette` on existing databases.
|
||||
|
||||
---
|
||||
|
||||
## `PATCH /api/artists/:id/checkup-flags`
|
||||
|
||||
Update artist portrait review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
|
||||
|
||||
Same rules as painting checkup flags: setting `fixed: true` also sets `checked: true`.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{ "checked": true, "fixed": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists/:id/debug-portrait-search`
|
||||
|
||||
Portrait image search for debug mode on the artist bio page (Custom Search → Google Arts & Culture → scrape → DuckDuckGo).
|
||||
|
||||
**Response** — same shape as painting debug search (`query`, `imageUrl`, `searchUrl`, `source`, optional `thumbUrl`, `sourceLabel`).
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists/:id/debug-portrait-search/more`
|
||||
|
||||
Up to 20 ranked portrait candidates for the **More** picker modal.
|
||||
|
||||
**Query:** `limit` (int, default 20, max 20)
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Leonardo da Vinci portrait",
|
||||
"searchUrl": "https://…",
|
||||
"source": "google-custom-search",
|
||||
"results": [
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search", "width": 1200, "height": 1600 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each result may include optional `width` and `height` (pixels). The **More** modal shows these under each thumbnail; when missing, the client probes dimensions via `GET /api/debug/image-proxy`.
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/artists/:id/fix-portrait`
|
||||
|
||||
Download a remote URL and replace the artist’s local portrait. Sets `checkup_fixed = true` and `checkup_checked = true`.
|
||||
|
||||
**Body** — same as `POST /api/paintings/:id/fix-image` (`imageUrl` required; optional `searchUrl`, `source`, `thumbUrl`).
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"portraitPath": "portraits/Leonardo_da_Vinci.jpg",
|
||||
"fixed": true,
|
||||
"checked": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/artists/:id/clear-portrait`
|
||||
|
||||
Delete the portrait file from disk, set `portrait_path = NULL`, and set both checkup flags. Used by debug **Clear**; the bio page shows an empty portrait slot (no placeholder).
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"portraitPath": null,
|
||||
"fixed": true,
|
||||
"checked": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/artists/:id/upload-portrait`
|
||||
|
||||
Upload a local image (base64 JSON body). Validates with `sharp`, resizes to portrait dimensions, sets checkup flags.
|
||||
|
||||
**Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"imageData": "<base64>",
|
||||
"mimeType": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists/:id/navigation`
|
||||
|
||||
Related artists for hall-to-hall navigation at the exit doorway. Derived from `painting_influences`:
|
||||
Related artists for hall-to-hall navigation at the exit doorway. Derived from **`painting_influence_sources`**:
|
||||
|
||||
- **Predecessors** — artists whose works influenced this artist’s paintings.
|
||||
- **Successors** — artists whose works were influenced by this artist’s paintings.
|
||||
- **Predecessors** — artists whose works (or artist records) influenced this artist’s paintings.
|
||||
- **Successors** — artists whose paintings were influenced by this artist’s works (via painting sources), or whose paintings cite this artist as a source (via artist sources).
|
||||
|
||||
Both lists are grouped by art movement and exclude the current artist.
|
||||
Both lists are grouped by art movement and exclude the current artist. Each artist appears once even when multiple influence edges exist.
|
||||
|
||||
**Response**
|
||||
|
||||
@@ -149,7 +637,9 @@ Both lists are grouped by art movement and exclude the current artist.
|
||||
|
||||
## `POST /api/artists/:id/preload-images`
|
||||
|
||||
Fast local scan: links paintings to files already on disk. Does **not** download from the internet (safe to call before opening the 3D gallery).
|
||||
**Public** — no curator login required.
|
||||
|
||||
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**
|
||||
|
||||
@@ -161,6 +651,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.
|
||||
@@ -169,7 +673,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",
|
||||
@@ -188,12 +704,31 @@ Painting detail with influence graph neighbours.
|
||||
"period_start_year": 1905, "period_end_year": 1907
|
||||
}
|
||||
],
|
||||
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ]
|
||||
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ],
|
||||
"annotations": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "Central figure",
|
||||
"body": "Short art-history note…",
|
||||
"category": "subject",
|
||||
"pos_x": 42.5,
|
||||
"pos_y": 38.0,
|
||||
"source_author": "E.H. Gombrich",
|
||||
"source": "The Story of Art",
|
||||
"source_url": "https://…",
|
||||
"sort_order": 0,
|
||||
"confidence": "curated"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`painting.has_influence_links` matches the flag on `GET /api/artists/:id` paintings (see above).
|
||||
|
||||
Both **`influencedBy`** and **`influenced`** are read from **`painting_influence_sources`** only (one row per edge). Painting-type sources appear in *Influenced By* when they are predecessors; in *Influenced* when this painting is the source of a later work. Artist and movement sources appear only in *Influenced By*. The legacy `painting_influences` table is not merged into these responses — it mirrors painting edges for scripts only.
|
||||
|
||||
In the UI, side-panel thumbnails use the painting’s local image with **letterboxing** (`object-fit: contain`) so the full composition is visible. Click a thumbnail to open that work’s detail view.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/paintings/:id/image`
|
||||
@@ -212,7 +747,9 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if
|
||||
|
||||
## Developer image audit
|
||||
|
||||
Routes for the **Checkup** page and **Debug mode** on painting detail. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
|
||||
**Curator login required** for every route in this section. See [Authentication](#authentication) above.
|
||||
|
||||
Routes for the **Checkup** page and **Debug mode** on painting detail and artist bio. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
|
||||
|
||||
### `GET /api/paintings/checkup`
|
||||
|
||||
@@ -252,6 +789,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.
|
||||
@@ -287,6 +840,8 @@ Google-family image search for debug / checkup (Custom Search → Google Arts &
|
||||
|
||||
Download a remote URL and replace the painting’s 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**
|
||||
@@ -308,6 +863,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
|
||||
}
|
||||
@@ -315,6 +872,83 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/paintings/:id/debug-image-search/more`
|
||||
|
||||
Up to 20 ranked painting image candidates for the **More** picker modal.
|
||||
|
||||
**Query:** `limit` (int, default 20, max 20)
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Andrei Rublev Trinity painting",
|
||||
"searchUrl": "https://…",
|
||||
"source": "google-arts",
|
||||
"results": [
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts", "width": 2400, "height": 1800 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Optional `width` / `height` on each result — see portrait **more** endpoint above.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/paintings/:id/clear-image`
|
||||
|
||||
Delete full + thumbnail files from disk, set `image_path` and `thumbnail_path` to `NULL`, and set both checkup flags. Used by debug **Clear**; detail view shows an empty frame (no placeholder, no on-demand refetch).
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"imagePath": null,
|
||||
"thumbnailPath": null,
|
||||
"fixed": true,
|
||||
"checked": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/paintings/:id/upload-image`
|
||||
|
||||
Upload a local painting image (base64 JSON body). Validates with `sharp`, writes full file, regenerates thumbnail, sets checkup flags.
|
||||
|
||||
**Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"imageData": "<base64>",
|
||||
"mimeType": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/paintings/:id`
|
||||
|
||||
Permanently remove a painting (debug **Remove entry** on painting detail). Deletes full + thumbnail files from disk, then deletes the `paintings` row. Related rows in `painting_influence_sources`, `painting_annotations`, and legacy `painting_influences` are removed by `ON DELETE CASCADE`.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"artistId": 7,
|
||||
"title": "Example Work"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` if the painting does not exist.
|
||||
|
||||
The client refetches the artist (and movement gallery when applicable), remounts the 3D hall, and opens the next or previous work in the catalog — or returns to the gallery if it was the last work.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/debug/image-proxy`
|
||||
|
||||
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
|
||||
@@ -327,23 +961,43 @@ Returns image bytes with appropriate `Content-Type`.
|
||||
|
||||
## Frontend helpers
|
||||
|
||||
The React client wraps these endpoints in `client/src/api/client.ts`:
|
||||
The React client wraps these endpoints in `client/src/api/client.ts`. All requests send `credentials: 'include'` for session cookies.
|
||||
|
||||
| Function | Maps to |
|
||||
|----------|---------|
|
||||
| `getAuthMe()` | `GET /api/auth/me` |
|
||||
| `loginCurator(user, pass)` | `POST /api/auth/login` |
|
||||
| `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 or on-demand API |
|
||||
| `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` |
|
||||
| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` |
|
||||
| `api.deletePainting(id)` | `DELETE /api/paintings/:id` |
|
||||
| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
|
||||
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
|
||||
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
|
||||
| `api.getArtistDebugPortraitSearchMore(id, limit?)` | `GET /api/artists/:id/debug-portrait-search/more` |
|
||||
| `api.fixArtistPortrait(id, imageUrl, context?)` | `POST /api/artists/:id/fix-portrait` |
|
||||
| `api.clearArtistPortrait(id)` | `POST /api/artists/:id/clear-portrait` |
|
||||
| `api.uploadArtistPortrait(id, file)` | `POST /api/artists/:id/upload-portrait` |
|
||||
| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` |
|
||||
|
||||
+135
-20
@@ -1,15 +1,21 @@
|
||||
# Art Gallery — database structure
|
||||
|
||||
PostgreSQL schema for the virtual gallery. Canonical DDL is intended to live in `db/schema.sql` when checked in; this document describes the logical model either way.
|
||||
PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run dev:migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model.
|
||||
|
||||
Connection settings come from `.env` (see [setup.md](setup.md)).
|
||||
Connection settings come from `.env` (dev) or `infra/docker/.env.prod` (prod scripts). See [environments.md](environments.md) and [setup.md](setup.md).
|
||||
|
||||
### One-time split (legacy `Gallery` → `gallery_prod` + `gallery_dev`)
|
||||
|
||||
Run [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) in **pgAdmin** on the dev PC (postgres superuser). Alternative: `npm run infra:db:split-dev-prod` with `PGUSER=postgres`.
|
||||
|
||||
## Overview
|
||||
|
||||
| Item | Typical value |
|
||||
|------|----------------|
|
||||
| Engine | PostgreSQL 14+ |
|
||||
| Database | `Gallery` |
|
||||
| Database (dev) | `gallery_dev` |
|
||||
| Database (prod) | `gallery_prod` |
|
||||
| Legacy name | `Gallery` (one-time split → prod + dev) |
|
||||
| App user | `gallery` |
|
||||
| Time fields | Integer years (negative = BCE) |
|
||||
|
||||
@@ -66,10 +72,15 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
|
||||
| `name` | VARCHAR(200) | |
|
||||
| `birth_year`, `death_year` | INTEGER | Nullable; used for timeline portrait placement |
|
||||
| `movement_id` | FK → `art_movements` | Primary movement |
|
||||
| `portrait_path` | VARCHAR(500) | Relative to `data/images/` |
|
||||
| `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run fetch-artist-bios`) |
|
||||
| `portrait_path` | VARCHAR(500) | Relative to `data/images/`; nullable after debug **Clear** |
|
||||
| `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run dev:fetch-artist-bios`) |
|
||||
| `wikipedia_title` | VARCHAR(300) | Source page title |
|
||||
| `century` | INTEGER | Rounded century bucket for seeding limits |
|
||||
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Portrait reviewed in debug workflow (gold border on bio when true) |
|
||||
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Portrait replaced, cleared, or uploaded via debug |
|
||||
| `palette_metadata` | JSONB | Enrichment from `Inputs/PainterPalette.csv` (`npm run dev:import-painter-palette`) |
|
||||
|
||||
Applied by `npm run dev:migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`) and `npm run dev:migrate:artist-palette` (`db/migrate-artist-palette.sql`).
|
||||
|
||||
### `artist_periods`
|
||||
|
||||
@@ -93,17 +104,66 @@ Phases within an artist’s 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 | |
|
||||
| `image_path` | VARCHAR(500) | Full-size local file |
|
||||
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D |
|
||||
| `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 |
|
||||
| `sort_order` | INTEGER | |
|
||||
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Reviewed in image checkup workflow (UI label: **Reviewed**) |
|
||||
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected via checkup / debug **Fix** |
|
||||
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected, cleared, or uploaded via checkup / debug |
|
||||
|
||||
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off.
|
||||
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 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`
|
||||
|
||||
Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | SERIAL PK | |
|
||||
| `painting_id` | FK → `paintings` | ON DELETE CASCADE |
|
||||
| `label` | VARCHAR(80) | Optional short heading (e.g. figure name) |
|
||||
| `body` | TEXT | Note text |
|
||||
| `category` | VARCHAR(30) | Default `subject`; also `technique`, `context`, `symbolism`, etc. |
|
||||
| `pos_x`, `pos_y` | NUMERIC(5,2) | Optional marker position on image (percent 0–100) |
|
||||
| `source_author` | VARCHAR(200) | e.g. Gombrich, Met catalog |
|
||||
| `source` | VARCHAR(500) | Citation label |
|
||||
| `source_url` | VARCHAR(500) | Reference link |
|
||||
| `sort_order` | INTEGER | Display order within the painting |
|
||||
| `confidence` | VARCHAR(20) | Default `curated`; Wikipedia pass uses `wikipedia` |
|
||||
|
||||
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 work’s `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`
|
||||
|
||||
@@ -123,7 +183,7 @@ Directed edges: *this painting* was influenced by *that painting*.
|
||||
|
||||
Unique constraint on `(painting_id, influenced_by_painting_id)`.
|
||||
|
||||
Used by the 3D gallery exit panel: predecessors are artists of `influenced_by_painting_id` works; successors are artists of paintings influenced by this artist’s works (see [API.md](API.md#get-apiartistsidnavigation)). Legacy table; new polymorphic links live in `painting_influence_sources`.
|
||||
**Legacy mirror table.** `npm run dev:update-influences` still inserts painting-to-painting rows here when curating data. The same edges are copied into `painting_influence_sources`. The **REST API does not read this table** for painting detail or hall navigation — use `painting_influence_sources` as the source of truth for display.
|
||||
|
||||
### `painting_influence_sources`
|
||||
|
||||
@@ -151,19 +211,74 @@ Polymorphic influence links: *this painting* was influenced by a **painting**, *
|
||||
|
||||
Unique index on `(painting_id, source_type, source_painting_id, source_artist_id, source_movement_id)` with COALESCE for null FKs.
|
||||
|
||||
Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_influence_links` and hall navigation (with legacy `painting_influences`).
|
||||
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
|
||||
|
||||
### `users`
|
||||
|
||||
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 |
|
||||
|
||||
**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 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`, `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`, `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`.
|
||||
|
||||
Admins browse this table in the app (**Activity** / `GET /api/audit*`). Each environment’s API uses its own DB (`gallery_dev` vs `gallery_prod`); **`users`**, **`session`**, and audit history are not synced by harmonize/`devtoprod:db:restore`.
|
||||
|
||||
Example query in pgAdmin:
|
||||
|
||||
```sql
|
||||
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id, l.details
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### `session`
|
||||
|
||||
PostgreSQL session store for `express-session` (`connect-pg-simple`). Not application data.
|
||||
|
||||
## Indexes
|
||||
|
||||
- `artists(movement_id)`, `artists(century)`
|
||||
- `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)`
|
||||
- `artists(checkup_checked)`, `artists(checkup_fixed)`
|
||||
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
|
||||
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
|
||||
- `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)`
|
||||
|
||||
## First-time setup
|
||||
|
||||
The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run admin grants as PostgreSQL superuser before `npm run migrate`:
|
||||
The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run **`db/setup-admin.sql`** as PostgreSQL superuser (or the grants below) before `npm run dev:migrate`:
|
||||
|
||||
```sql
|
||||
GRANT CREATE ON SCHEMA public TO gallery;
|
||||
@@ -176,10 +291,10 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO gallery;
|
||||
Then:
|
||||
|
||||
```bash
|
||||
npm run migrate
|
||||
npm run seed
|
||||
npm run fetch-artist-bios
|
||||
npm run expand-catalog
|
||||
npm run dev:migrate
|
||||
npm run dev:seed
|
||||
npm run dev:fetch-artist-bios
|
||||
npm run dev:expand-catalog
|
||||
```
|
||||
|
||||
## Data conventions
|
||||
@@ -187,5 +302,5 @@ npm run expand-catalog
|
||||
- **Year zero** is not used; BCE years are negative integers.
|
||||
- **Image paths** are relative to `IMAGE_DIR` (default `./data/images`), e.g. `portraits/Claude_Monet.jpg`, `paintings/thumbs/Raphael_The_School_of_Athens_thumb.jpg`.
|
||||
- **Seeding cap**: curated ingest targets at most ~100 artists per century to keep the catalog manageable.
|
||||
- **Catalog expansion**: `scripts/famous-paintings-data.js` plus `npm run expand-catalog` raises thin artist catalogs to at least six notable paintings (`MIN_PAINTINGS`, default 6).
|
||||
- **Biographies**: `bio_short` and `bio_full` are populated by `npm run fetch-artist-bios` from English Wikipedia lead sections; `wikipedia_title` on the artist row is the source article.
|
||||
- **Catalog expansion**: `scripts/famous-paintings-data.js` plus `npm run dev:expand-catalog` raises thin artist catalogs to at least six notable paintings (`MIN_PAINTINGS`, default 6).
|
||||
- **Biographies**: `bio_short` and `bio_full` are populated by `npm run dev:fetch-artist-bios` from English Wikipedia lead sections; `wikipedia_title` on the artist row is the source article.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
# Gallery — command reference (FAC)
|
||||
|
||||
Quick cheat sheet for daily operations. All `npm` commands run from the **repository root** unless noted.
|
||||
|
||||
**Default target: dev.** Day-to-day commands use `gallery_dev`, repo `data/images/`, and https://devgallery.mysuperlab.netcraze.pro. Prod (`gallery_prod`, Docker, `prod:docker:publish`) is for **scheduled releases** (~weekly) — see [Promote dev → prod](#promote-dev--prod-scheduled-release) below.
|
||||
|
||||
**Script prefixes:** `dev:` → `gallery_dev` / dev PC · `prod:` → production / TrueNAS · `devtoprod:` → promote dev → prod · `prodto:dev:` → refresh dev from prod · `infra:` → one-time setup
|
||||
|
||||
**Environments:**
|
||||
|
||||
| | Dev | Prod |
|
||||
|---|-----|------|
|
||||
| URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro |
|
||||
| Host | Dev PC `192.168.10.70:5173` | TrueNAS `192.168.10.122:5173` |
|
||||
| Database | `gallery_dev` | `gallery_prod` |
|
||||
|
||||
Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md)
|
||||
|
||||
---
|
||||
|
||||
## Start and stop servers
|
||||
|
||||
### Start — public dev (Keenetic URL)
|
||||
|
||||
```powershell
|
||||
cd T:\Repo\Gallery
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Vite on **:5173**, API on **:3451**. Open https://devgallery.mysuperlab.netcraze.pro or http://localhost:5173.
|
||||
|
||||
### Start — local HMR (no Keenetic)
|
||||
|
||||
Two terminals:
|
||||
|
||||
```powershell
|
||||
npm run dev:server # API — PORT from .env (default 3451)
|
||||
npm run dev:client # Vite on :5173
|
||||
```
|
||||
|
||||
### Start — production-style (single Node process, built SPA)
|
||||
|
||||
```powershell
|
||||
npm run prod:start # build client + serve on PORT from .env
|
||||
# or:
|
||||
npm run prod:build
|
||||
npm run dev:start
|
||||
```
|
||||
|
||||
### Stop dev servers
|
||||
|
||||
| Method | When |
|
||||
|--------|------|
|
||||
| **Ctrl+C** in the terminal running `dev:web` / `dev:server` / `dev:client` | Normal stop |
|
||||
| Close the terminal tab | Same effect |
|
||||
|
||||
Free ports **5173**, **3451**, **3520** before DB maintenance or if “port in use” errors appear.
|
||||
|
||||
**Find what holds a port (PowerShell):**
|
||||
|
||||
```powershell
|
||||
netstat -ano | findstr ":5173 :3451"
|
||||
# Stop by PID:
|
||||
Stop-Process -Id <PID> -Force
|
||||
```
|
||||
|
||||
### Stop / restart production (TrueNAS)
|
||||
|
||||
| Action | Where |
|
||||
|--------|-------|
|
||||
| **Stop** | TrueNAS Web UI → Apps → **gallery-web** → Stop |
|
||||
| **Restart** | Same → Restart (after `docker:publish` or config change) |
|
||||
| **Update image** | Dev PC: `npm run prod:docker:publish` → restart app on TrueNAS |
|
||||
|
||||
---
|
||||
|
||||
## First-time install
|
||||
|
||||
```powershell
|
||||
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)
|
||||
```
|
||||
|
||||
**Curator auth (after migrate):** set in `.env` before first `npm run dev:migrate` if the DB has no curator yet:
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-long-random-secret
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
||||
|
||||
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 | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
||||
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
||||
|
||||
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. Prod and dev keep **separate** `users` tables: `devtoprod:db:restore` and harmonize never copy staff accounts. If create fails with a confusing “already exists” after a restore, serial sequences may be lagging — current restore syncs them to `MAX(id)`, and user create re-syncs `users_id_seq` before insert.
|
||||
|
||||
**Activity page:** after admin login, header → **Activity** — filterable curator action log (date/time, curator, action, resource, details, IP) plus summary charts. Reads the DB for that environment (`gallery_dev` on devgallery / `npm run dev:web`, `gallery_prod` on prod).
|
||||
|
||||
**Audit log (SQL / pgAdmin on `gallery_dev` or `gallery_prod`):**
|
||||
|
||||
```sql
|
||||
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
**Prod auth env** (TrueNAS app or `infra/docker/.env.prod`):
|
||||
|
||||
```env
|
||||
SESSION_SECRET=long-random-secret
|
||||
SESSION_COOKIE_SECURE=true
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
Run `npm run dev:migrate` against prod DB after first deploy with auth vars set (creates tables + bootstrap curator if `users` is empty).
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:migrate` | Apply `db/schema.sql` + incremental migrations (safe to re-run) |
|
||||
| `npm run dev:setup` | `migrate` + `seed` — fresh catalog from Wikipedia data |
|
||||
| `npm run infra:db:split-dev-prod` | One-time: legacy `Gallery` → `gallery_prod` + `gallery_dev` (needs `PGUSER=postgres`) |
|
||||
| `npm run prodto:dev:db` | Clone `gallery_prod` → `gallery_dev` (TEMPLATE); also runs image sync from prod |
|
||||
| `npm run dev:db:backup` | Dev data-only backup → `db/DataBackup/*.txt` + `.zip` |
|
||||
| `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 catalog into **prod** (skips `users` / `session` / `curator_audit_log`; syncs serial sequences; 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`.
|
||||
|
||||
**Dev `.env` essentials:**
|
||||
|
||||
```env
|
||||
DB_NAME=gallery_dev
|
||||
PORT=3451
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
SESSION_SECRET=your-long-random-secret
|
||||
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via Keenetic → Secure cookie)
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import and enrich catalog data
|
||||
|
||||
Run in this order on a **new** or **re-seeded** database:
|
||||
|
||||
| # | Command | What it does |
|
||||
|---|---------|----------------|
|
||||
| 1 | `npm run dev:seed` | Eras, movements, artists, one flagship painting per artist |
|
||||
| 2 | `npm run dev:sync-image-paths` | Import paintings from `data/images/paintings/`; link paths in DB |
|
||||
| 3 | `npm run dev:fetch-artist-images` | Portraits → `data/images/portraits/`, set `portrait_path` |
|
||||
| 4 | `npm run dev:fetch-artist-bios` | Wikipedia bios → `bio_short` / `bio_full` |
|
||||
| 5 | `npm run dev:expand-catalog` | Add famous works per artist (below `MIN_PAINTINGS`) |
|
||||
| 6 | `npm run dev:update-influences` | Influence graph (detail panels, 3D hall lamps) |
|
||||
| 7 | `npm run dev:fetch-images -- --limit=50` | Download missing painting files (batch) |
|
||||
|
||||
**One-shot bootstrap:** `npm run dev:setup` = steps 1 + migrate only; still run 2–7 for a full catalog.
|
||||
|
||||
### Useful flags
|
||||
|
||||
```powershell
|
||||
npm run dev:sync-image-paths -- --dry-run
|
||||
npm run dev:fetch-artist-images -- --force
|
||||
npm run dev:fetch-artist-bios -- --force
|
||||
npm run dev:expand-catalog -- --fetch-images
|
||||
npm run dev:fetch-images -- --artist="Claude Monet"
|
||||
npm run dev:fetch-images -- --limit=50 --max-wait=120
|
||||
npm run dev:update-influences -- --discover
|
||||
npm run dev:discover-influences # discovery only, no curated insert
|
||||
```
|
||||
|
||||
### Optional migrations / imports
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:migrate:thumbnails` | Add thumbnail columns |
|
||||
| `npm run dev:migrate:influence-sources` | `painting_influence_sources` table + backfill |
|
||||
| `npm run dev:migrate:checkup-flags` | Review flags on `paintings` |
|
||||
| `npm run dev:migrate:artist-checkup-flags` | Review flags on `artists` |
|
||||
| `npm run dev:migrate:painting-annotations` | Art-history notes table |
|
||||
| `npm run dev:migrate:artist-palette` | `palette_metadata` JSONB on artists |
|
||||
| `npm run dev:import-painter-palette` | Enrich from `Inputs/PainterPalette.csv` |
|
||||
| `npm run dev:update-painting-annotations` | Load curated notes |
|
||||
| `npm run dev:update-painting-annotations -- --wikipedia` | Add Wikipedia intro sentences |
|
||||
|
||||
### Audit / export
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:audit-painting-images` | Thumb vs full aspect-ratio mismatches |
|
||||
| `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` (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`.
|
||||
|
||||
---
|
||||
|
||||
## Images (local files)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:fetch-images` | Search/download missing painting files (alias: `search-missing-paintings`) |
|
||||
| `npm run dev:fetch-artist-images` | Download or link artist portraits |
|
||||
| `npm run dev:sync-image-paths` | Align DB paths with files on disk; import new rows |
|
||||
| `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images |
|
||||
| `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) |
|
||||
| `npm run devtoprod:thumbnails` | Both of the above — run on dev before promote backup/sync |
|
||||
| `npm run devtoprod:release` | Config-driven full promote (see [deploy-dev-to-prod.md](deploy-dev-to-prod.md#one-command-release-automated)) |
|
||||
|
||||
**Local paths:** `data/images/portraits/`, `data/images/paintings/`, `data/images/paintings/thumbs/`
|
||||
|
||||
---
|
||||
|
||||
## Dev ↔ prod image sync (SMB)
|
||||
|
||||
SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS.
|
||||
|
||||
```powershell
|
||||
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 (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 **0–7** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
|
||||
|
||||
---
|
||||
|
||||
## Docker and production deploy
|
||||
|
||||
| Command | Where | Description |
|
||||
|---------|-------|-------------|
|
||||
| `npm run prod:docker:publish` | Dev PC, **Admin** PowerShell, Docker Desktop running | Build + push `gallery-web:latest` to Gitea |
|
||||
| `npm run prod:docker:push-only` | Same | Push only (skip rebuild) |
|
||||
| `.\infra\docker\save-for-truenas.ps1` | Dev PC | Save image to `gallery-web.tar` (offline deploy) |
|
||||
|
||||
**Before first deploy:** `npm run prod:docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml`.
|
||||
|
||||
**After code changes:** `npm run prod:docker:publish` → restart **gallery-web** on TrueNAS.
|
||||
|
||||
```powershell
|
||||
docker login gitea.mysuperlab.netcraze.pro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build frontend
|
||||
|
||||
```powershell
|
||||
npm run prod:build # client → client/dist/
|
||||
cd client && npm run build && cd ..
|
||||
```
|
||||
|
||||
Prod container serves `client/dist/` from the Docker image (rebuild image after UI changes).
|
||||
|
||||
---
|
||||
|
||||
## Verify (health checks)
|
||||
|
||||
```powershell
|
||||
# Dev (servers running)
|
||||
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
|
||||
curl.exe -s http://192.168.10.70:5173/api/bounds
|
||||
|
||||
# Prod (bypass Keenetic)
|
||||
curl.exe -s http://192.168.10.122:5173/api/bounds
|
||||
|
||||
# Prod (public)
|
||||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||||
```
|
||||
|
||||
Expect JSON with `min_year` / `max_year`. HTML shell only from `curl` on `/` is normal (Vite dev).
|
||||
|
||||
**PowerShell note:** use `curl.exe`, not `curl` — PowerShell aliases `curl` to `Invoke-WebRequest` (no `-k` flag).
|
||||
|
||||
---
|
||||
|
||||
## Promote dev → prod (scheduled release)
|
||||
|
||||
~Weekly (or when explicitly releasing to prod). Not part of daily dev. Full runbook with per-change decision matrix and rollback: [deploy-dev-to-prod.md](deploy-dev-to-prod.md).
|
||||
|
||||
**One command (recommended):** copy `infra/deploy/devtoprod.config.example.json` → `infra/deploy/devtoprod.config.json`, edit SMB/git settings, then:
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:release
|
||||
# or: deploy-dev-to-prod.cmd
|
||||
```
|
||||
|
||||
Dry-run: `npm run devtoprod:release -- -DryRun`. The script pauses for a manual **gallery-web** restart on TrueNAS before verify.
|
||||
|
||||
**Manual steps** (partial releases):
|
||||
|
||||
1. Test on https://devgallery.mysuperlab.netcraze.pro
|
||||
2. `npm run devtoprod:thumbnails` (if paintings/portraits changed — rebuild thumb files + DB paths on dev)
|
||||
3. `npm run dev:db:backup`
|
||||
4. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (if DB/catalog changed)
|
||||
5. `npm run devtoprod:images` (if images changed)
|
||||
6. `npm run prod:docker:publish` (if code changed)
|
||||
7. Restart **gallery-web** on TrueNAS
|
||||
8. Verify https://gallery.mysuperlab.netcraze.pro
|
||||
|
||||
---
|
||||
|
||||
## Keenetic (router)
|
||||
|
||||
Both rules: **protocol to device = `http`**, **Preserve Host = ON**.
|
||||
|
||||
| Domain | Upstream |
|
||||
|--------|----------|
|
||||
| `devgallery.mysuperlab.netcraze.pro` | `192.168.10.70:5173` |
|
||||
| `gallery.mysuperlab.netcraze.pro` | `192.168.10.122:5173` |
|
||||
|
||||
Wrong IP or `https` to device → **502 / 504** (`Server: Web server`).
|
||||
|
||||
---
|
||||
|
||||
## Git (Gitea)
|
||||
|
||||
```powershell
|
||||
git status
|
||||
git add .
|
||||
git commit -m "Your message"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
| Document | Contents |
|
||||
|----------|----------|
|
||||
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Step-by-step release runbook (code, DB, data, images) |
|
||||
| [environments.md](environments.md) | Full dev/prod walkthrough |
|
||||
| [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 |
|
||||
@@ -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 painting’s 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 ?~~ — done for monitoring: admin **Activity** page + `/api/audit` over `curator_audit_log` (filters, summary, per-env DB). Checkup flags remain the painting review markers.
|
||||
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); browse via admin **Activity** page — [DB_structure.md](DB_structure.md#curator_audit_log) / [API.md](API.md#audit-log-admin).
|
||||
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)
|
||||
|
||||
+334
-62
@@ -1,16 +1,18 @@
|
||||
# Art Gallery — architecture basics
|
||||
|
||||
Interactive virtual museum spanning art history: zoomable timeline, branching movement flow, 3D gallery halls, and painting influence graphs. See [README.md](../README.md) for quick start.
|
||||
Interactive virtual museum spanning art history: zoomable timeline with event guides, branching movement flow, 3D gallery halls, painting influence graphs, and art-history annotations on detail pages. See [README.md](../README.md) for quick start.
|
||||
|
||||
## Concept
|
||||
|
||||
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; paintings on the walls, open centre, single exit for influence-based navigation.
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`).
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||
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).
|
||||
|
||||
@@ -31,79 +33,211 @@ Gallery/
|
||||
├── server/ # Express API, DB pool, image service
|
||||
├── client/ # React/Vite frontend
|
||||
│ ├── src/ # Source (components, pages, 3D scene)
|
||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit)
|
||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (artist + movement modes; WebGL context-loss recovery)
|
||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── 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
|
||||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── i18n/ # react-i18next bootstrap
|
||||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
│ │ ├── utils/debugMode.ts # Debug mode localStorage toggle
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||
│ └── dist/ # Production build (served by API when present)
|
||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||
│ ├── seed-wikipedia.js
|
||||
│ ├── seed-catalog-data.js
|
||||
│ ├── sync-image-paths.js
|
||||
│ ├── fetch-artist-images.js
|
||||
│ ├── fetch-artist-bios.js
|
||||
│ ├── expand-paintings.js
|
||||
│ ├── famous-paintings-data.js
|
||||
│ ├── fetch-missing-images.js
|
||||
│ ├── find-duplicate-paintings.js
|
||||
│ ├── audit-influence-duplicates.js
|
||||
│ ├── painter-palette-lib.js
|
||||
│ ├── import-painter-palette.js
|
||||
│ └── image-fetcher.js
|
||||
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
|
||||
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
|
||||
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
||||
├── db/ # SQL schema and migrations (when present)
|
||||
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
||||
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
|
||||
├── deploy/ # Legacy nginx + systemd examples (optional)
|
||||
├── infra/docker/ # Production Dockerfile, TrueNAS compose, deploy scripts
|
||||
├── Documentation/ # This folder
|
||||
│ └── environments.md # Dev/prod URLs, DB split, sync, deploy
|
||||
└── .env # DB and port config (not committed)
|
||||
```
|
||||
|
||||
## Runtime modes
|
||||
|
||||
### Production-style (single process)
|
||||
**Default:** develop and test on **dev** (`gallery_dev`, devgallery URL). **Production** is updated on a scheduled release (~weekly), not on every edit. See [environments.md — Development-first workflow](environments.md#development-first-workflow-default).
|
||||
|
||||
### Public development (`dev:web`) — primary
|
||||
|
||||
```bash
|
||||
npm run server # http://localhost:3001
|
||||
npm run dev:web # Vite :5173 + API :3451 — https://devgallery.mysuperlab.netcraze.pro
|
||||
```
|
||||
|
||||
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist` if it exists.
|
||||
Uses database **`gallery_dev`** on the same PostgreSQL host.
|
||||
|
||||
### Development (two processes)
|
||||
### Production (TrueNAS Docker) — scheduled releases
|
||||
|
||||
Production runs in **`gallery-web`** on TrueNAS port **5173**, database **`gallery_prod`**, public URL **https://gallery.mysuperlab.netcraze.pro**. Images: `/mnt/BasePool/Applications/Gallery/data/images` (SMB share **`Gallery`**). Deploy via `npm run prod:docker:publish` and the promote checklist in [environments.md](environments.md).
|
||||
|
||||
### Production-style single process (local)
|
||||
|
||||
```bash
|
||||
npm run dev:server # API on :3001
|
||||
npm run prod:start # build client + serve on PORT from .env
|
||||
```
|
||||
|
||||
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist`.
|
||||
|
||||
### Local HMR (two processes)
|
||||
|
||||
```bash
|
||||
npm run dev:server # API on PORT from .env
|
||||
npm run dev:client # Vite on :5173, proxies /api and /images
|
||||
```
|
||||
|
||||
Use the Vite URL during frontend work for HMR.
|
||||
Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) proxied the public domain to Vite `:5173`.
|
||||
|
||||
## User navigation flow
|
||||
|
||||
```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 to Gallery| G
|
||||
G -->|back door / Wings / Exit| H[Wing navigator]
|
||||
H -->|pick wing| G
|
||||
H -->|Exit to Timeline| A
|
||||
G -->|front arch / E| G
|
||||
G -->|Back to Timeline| A
|
||||
A -->|click portrait| B[Artist bio — Wikipedia text]
|
||||
B -->|Enter Gallery| C[Artist hall — 3D]
|
||||
C -->|click painting| D[Painting detail + influences]
|
||||
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`):
|
||||
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||||
|
||||
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||
|--------|----------|---------------|----------------|-----|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||
|
||||
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||
|
||||
| View | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||||
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): the movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||||
|
||||
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||||
### 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:
|
||||
|
||||
1. `GET /api/bounds` — initialise the year range.
|
||||
2. `GET /api/timeline?start=…&end=…` — all eras and movements for that range.
|
||||
3. `GET /api/artists?timeline=1` — lightweight artist rows (portraits, lifespan, movement colour; no full biography text).
|
||||
|
||||
Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `viewEnd` state. `MovementBands.tsx` and `Timeline.tsx` filter what is visible for the current window — they do not trigger new API calls.
|
||||
|
||||
**Loading indicators** (`GalleryLoadingMarker.tsx`) keep the user informed while data is still arriving:
|
||||
|
||||
| Marker | When |
|
||||
|--------|------|
|
||||
| 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…”** / **“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.
|
||||
|
||||
### Timeline year labels
|
||||
|
||||
Year ticks along the bottom of the era bar use **large, high-contrast** labels (bold cream text with shadow). The active range in the control row (e.g. `1400 CE — 1900 CE`) uses the same stronger styling.
|
||||
|
||||
Label density is **dynamic**: `chooseTimelineTickInterval()` in `timelineView.ts` picks a “nice” step (1, 2, 5, 10, … 5000 years) from the visible span and measured bar width so labels stay ~76 px apart. Zoomed-out overviews show fewer dates; zooming in reveals finer steps automatically.
|
||||
|
||||
### Timeline controls
|
||||
|
||||
@@ -120,18 +254,21 @@ Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The
|
||||
|
||||
Major world events appear on the era bar as pin markers (single years) or shaded spans (e.g. World War I, World War II). Data lives in `client/src/data/historical-events.ts` — not in PostgreSQL. Labels appear when zoomed in enough; tooltips always show name and dates. Edit `HISTORICAL_EVENTS` and rebuild the client to add or change markers.
|
||||
|
||||
**Vertical guides:** `TimelineEventGuides.tsx` draws faint gold lines (point events) or shaded bands (spans) from the marker row **down through the movement flow**, aligned to the same year scale. Guides are visual only (`pointer-events: none`); click-to-zoom stays on the markers in `Timeline.tsx`.
|
||||
|
||||
### 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 parent’s 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 |
|
||||
| Filtering | Same rule as the API: only movements with at least one artist active in the visible year range |
|
||||
| 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
|
||||
|
||||
@@ -149,38 +286,48 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Scroll wheel on flow canvas | Zoom (same range as timeline) |
|
||||
| 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 the **artist filter** modal for that movement, then the movement gallery |
|
||||
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks.
|
||||
**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.
|
||||
|
||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links (`painting_influence_sources`, plus legacy `painting_influences` for hall navigation) are separate and drive the 3D exit picker and detail panels.
|
||||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
|
||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
||||
|
||||
## Virtual gallery (3D halls)
|
||||
|
||||
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
|
||||
|
||||
Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog:
|
||||
|
||||
| Rule | Implementation |
|
||||
|------|----------------|
|
||||
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings |
|
||||
| Catalog depth | Most artists target **≥ 6** notable works via `npm run 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 |
|
||||
| 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 **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 artist’s **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 marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). 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 |
|
||||
| After image fix | Debug **Fix it** updates the gallery session, busts texture cache (`?v=N`), and returns to the hall with the new image and gold frame |
|
||||
|
||||
**Controls:**
|
||||
**Controls (artist hall):**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
@@ -192,71 +339,188 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
||||
| Click painting | Open detail view |
|
||||
| Exit doorway / `E` / **Exit →** header button | Open path picker |
|
||||
|
||||
Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data.
|
||||
Predecessors and successors come from **`painting_influence_sources`** (painting and artist sources). Empty lists mean no influence edges are recorded yet for that artist — run `npm run dev:update-influences` or extend `art-influences-data.js`.
|
||||
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
||||
### Movement galleries
|
||||
|
||||
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 | Paintings by **selected** artists in that movement, sorted chronologically |
|
||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
|
||||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||
| 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, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, 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, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — 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 |
|
||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||
| 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`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||
| 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; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
||||
| 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 (shared rail: 40 cm above the tallest frame in the wing) |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| 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):**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Walk / turn / drag | Same as artist hall |
|
||||
| Click painting | Open detail view (returns to the same 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
|
||||
|
||||
**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 ~100–200 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 (click a frame) or from influence thumbnails on another work’s detail page.
|
||||
Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another work’s detail page.
|
||||
|
||||
| Layer | What you see |
|
||||
|-------|----------------|
|
||||
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails, artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
|
||||
| **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 |
|
||||
|
||||
**Controls:**
|
||||
|
||||
| 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 work’s detail (different artist allowed) |
|
||||
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||||
| **← Back to Gallery** | Return to the hall you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Gallery** | Return to the hall 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 artist’s 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.
|
||||
|
||||
### Debug mode (developer)
|
||||
|
||||
When **Debug mode** is enabled from the home header, painting detail shows a bottom-left panel with image search preview and **Checked** / **Fix it** buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
|
||||
**Curator login required.** Debug tools are hidden until you sign in from the home header (**Curator login**). After login, enable **Debug mode** from the same header area.
|
||||
|
||||
When debug mode is on, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
|
||||
|
||||
Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens the **More** search-results modal automatically whenever you open a painting or artist bio while debug mode is on.
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins.
|
||||
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
||||
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
||||
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
||||
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
||||
- **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.
|
||||
- **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL).
|
||||
- **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 / 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
|
||||
|
||||
| Role | Who | Can do |
|
||||
|------|-----|--------|
|
||||
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
|
||||
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
|
||||
| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
|
||||
|
||||
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
|
||||
|
||||
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. Admins browse reports on the **Activity** page (`AuditPage.tsx` / `/api/audit`); the API reads whatever DB the server is connected to (`DB_NAME`: `gallery_dev` on dev, `gallery_prod` on prod). See [DB_structure.md](DB_structure.md#curator_audit_log).
|
||||
|
||||
## Developer tools (image audit)
|
||||
|
||||
Optional workflow for curating 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 |
|
||||
|---------|--------|---------|
|
||||
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail |
|
||||
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||||
| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Search preview + two action buttons |
|
||||
| **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 |
|
||||
| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) |
|
||||
| **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)
|
||||
### Debug panel (painting detail and artist bio)
|
||||
|
||||
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **two buttons**:
|
||||
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and action buttons in two or three rows:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) |
|
||||
| **Fix it** | Replaces local full + thumbnail from the search result via `POST /api/paintings/:id/fix-image`; sets **Fixed** and **Reviewed** |
|
||||
| Button | Painting detail | Artist bio |
|
||||
|--------|-----------------|------------|
|
||||
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags |
|
||||
| **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 (`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**, the detail image, gallery textures, and frame colour (gold if reviewed) update without a full page reload. **Back to Gallery** returns to the live hall session, not a stale snapshot.
|
||||
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.
|
||||
|
||||
### Checkup page
|
||||
|
||||
@@ -264,15 +528,23 @@ After **Fix it**, the detail image, gallery textures, and frame colour (gold if
|
||||
|
||||
**Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**.
|
||||
|
||||
Run `npm run migrate:checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. `PATCH …/checkup-flags`) are registered.
|
||||
Run `npm run dev:migrate:checkup-flags`, `npm run dev:migrate:artist-checkup-flags`, and `npm run dev:migrate:painting-annotations` once on existing databases. Load notes with `npm run dev:update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev:start` (or `npm run dev:server`) so new routes are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode.
|
||||
|
||||
See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings).
|
||||
Server-side auth lives in `server/middleware/session.js`, `server/middleware/auth.js`, `server/routes/auth.js`, and `server/audit-log.js`. Client auth context: `client/src/context/AuthContext.tsx`.
|
||||
|
||||
See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#duplicate-paintings).
|
||||
|
||||
## Related docs
|
||||
|
||||
| Document | Contents |
|
||||
|----------|----------|
|
||||
| [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 |
|
||||
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
|
||||
@@ -7,13 +7,30 @@ 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 painting’s full picture must also **regenerate or remove** that painting’s 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
|
||||
|
||||
```text
|
||||
data/images/
|
||||
├── portraits/ # Artist headshots
|
||||
│ └── Claude_Monet.jpg
|
||||
├── portraits/ # Artist headshots (display ~900px wide)
|
||||
│ ├── Claude_Monet.jpg
|
||||
│ └── thumbs/ # Timeline thumbnails (~256px)
|
||||
│ └── Claude_Monet_thumb.jpg
|
||||
└── paintings/
|
||||
├── Claude_Monet_Water_Lilies.jpg
|
||||
└── thumbs/
|
||||
@@ -22,51 +39,70 @@ data/images/
|
||||
|
||||
File names are sanitised `{Artist}_{Title}.{ext}`. The image service can rediscover files on disk even when DB paths are empty (`server/image-service.js` → `syncPaintingFromDisk`).
|
||||
|
||||
### Dev vs production image storage
|
||||
|
||||
| Environment | Path on disk | Sync |
|
||||
|-------------|--------------|------|
|
||||
| **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` |
|
||||
|
||||
**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 artist’s `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
|
||||
|
||||
| Script | npm command | Role |
|
||||
|--------|-------------|------|
|
||||
| `seed-wikipedia.js` | `npm run seed` | Initial eras, movements, artists, paintings, influences |
|
||||
| `fetch-artist-bios.js` | `npm run fetch-artist-bios` | Wikipedia intros → `bio_short` / `bio_full` |
|
||||
| `seed-wikipedia.js` | `npm run dev:seed` | Initial eras, movements, artists, one flagship painting per artist |
|
||||
| `seed-catalog-data.js` | *(data only)* | Eras, movements, artist metadata consumed by seed |
|
||||
| `sync-image-paths.js` | `npm run dev:sync-image-paths` | Import painting rows from disk; set `image_path` / `thumbnail_path` |
|
||||
| `fetch-artist-images.js` | `npm run dev:fetch-artist-images` | Download or link artist portraits under `data/images/portraits/` |
|
||||
| `fetch-artist-bios.js` | `npm run dev:fetch-artist-bios` | Wikipedia intros → `bio_short` / `bio_full` |
|
||||
| `famous-paintings-data.js` | *(data only)* | Curated list of notable works per artist |
|
||||
| `expand-paintings.js` | `npm run expand-catalog` | Inserts works from data file for thin catalogs |
|
||||
| `art-influences-data.js` | *(data only)* | Curated painting-to-painting influence edges |
|
||||
| `update-influences.js` | `npm run update-influences` | Applies influence graph; creates missing artists/works |
|
||||
| `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk |
|
||||
| `expand-paintings.js` | `npm run dev:expand-catalog` | Inserts works from data file for thin catalogs |
|
||||
| `art-influences-data.js` | *(data only)* | Curated influence edges (painting / artist / movement) |
|
||||
| `update-influences.js` | `npm run dev:update-influences` | Applies influence graph; creates missing artists/works |
|
||||
| `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 |
|
||||
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` |
|
||||
| `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
|
||||
| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows |
|
||||
| `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns |
|
||||
| `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 |
|
||||
| `find-duplicate-paintings.js` | `npm run dev:find-duplicates` | Report exact and near-duplicate catalog rows |
|
||||
| `migrate-checkup-flags.js` | `npm run dev:migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
```text
|
||||
migrate → seed → fetch-artist-bios → expand-catalog → update-influences → fetch-images (per artist or batch) → build client
|
||||
migrate → seed → sync-image-paths → fetch-artist-images → fetch-artist-bios → expand-catalog → update-influences → fetch-images (per artist or batch) → build client
|
||||
```
|
||||
|
||||
1. **Seed** creates the base catalog (often one flagship painting per modern artist).
|
||||
2. **fetch-artist-bios** fills biography fields for every artist with a `wikipedia_title`.
|
||||
3. **expand-catalog** brings each artist up to at least **6** notable works (configurable via `MIN_PAINTINGS`).
|
||||
4. **fetch-images** downloads artwork files; the 3D gallery needs local files for reliable textures.
|
||||
1. **Seed** creates the base catalog (one flagship painting per artist; ~100 artists).
|
||||
2. **sync-image-paths** imports additional paintings when `data/images/paintings/` already contains files from a full clone (filename pattern `{Artist}_{Title}.jpg`).
|
||||
3. **fetch-artist-images** sets `portrait_path` from local files or Wikipedia.
|
||||
4. **fetch-artist-bios** fills biography fields for every artist with a `wikipedia_title`.
|
||||
5. **expand-catalog** brings each artist up to at least **6** notable works (configurable via `MIN_PAINTINGS`).
|
||||
6. **update-influences** loads the influence graph (*Influenced By* / *Influenced* panels, 3D hall lamps, exit navigation).
|
||||
7. **fetch-images** downloads artwork files still missing on disk; the 3D gallery needs local files for reliable textures.
|
||||
|
||||
## Seeding pipeline
|
||||
|
||||
`npm run seed` runs `scripts/seed-wikipedia.js`, which:
|
||||
`npm run dev:seed` runs `scripts/seed-wikipedia.js`, which:
|
||||
|
||||
1. Inserts **historical eras** and **art movements** (curated date ranges and colours).
|
||||
1. Inserts **historical eras** and **art movements** (curated date ranges and colours from `seed-catalog-data.js`).
|
||||
2. For each curated **artist**:
|
||||
- Creates **artist periods** and **paintings**.
|
||||
- May download portraits and painting images (depending on seed script version).
|
||||
3. Writes **painting_influences** edges from curated scholarship references.
|
||||
- Creates **artist periods** and one **flagship painting**.
|
||||
- May download portraits and painting images when run with `--fetch-images`.
|
||||
3. Does **not** insert influence edges — run `npm run dev:update-influences` after seed (see [Painting influence graph](#painting-influence-graph)).
|
||||
|
||||
Those influence edges power **3D hall navigation**: predecessors and successors at each artist’s exit doorway are computed from this table (see `GET /api/artists/:id/navigation` in [API.md](API.md)).
|
||||
Those influence edges power **3D hall navigation** and painting detail panels via **`painting_influence_sources`** (see `GET /api/artists/:id/navigation` and `GET /api/paintings/:id` in [API.md](API.md)).
|
||||
|
||||
Artists are grouped by movement and century; the seed list targets at most ~100 artists per century.
|
||||
|
||||
## Artist biographies
|
||||
|
||||
`npm run fetch-artist-bios` reads each artist’s `wikipedia_title`, fetches the English Wikipedia **lead section**, and stores:
|
||||
`npm run dev:fetch-artist-bios` reads each artist’s `wikipedia_title`, fetches the English Wikipedia **lead section**, and stores:
|
||||
|
||||
| Field | Content |
|
||||
|-------|---------|
|
||||
@@ -91,15 +127,15 @@ The bio page (`ArtistBio.tsx`) shows lifespan, movement, summary, full text, and
|
||||
|
||||
## Expanding thin catalogs
|
||||
|
||||
Many seed artists arrive with only one famous painting. `npm run expand-catalog` runs `scripts/expand-paintings.js`, which:
|
||||
Many seed artists arrive with only one famous painting. `npm run dev:expand-catalog` runs `scripts/expand-paintings.js`, which:
|
||||
|
||||
1. Finds artists with fewer than `MIN_PAINTINGS` (default **6**).
|
||||
2. Inserts rows from `scripts/famous-paintings-data.js` that are not already present (normalized title matching skips duplicates).
|
||||
3. Sets `wikipedia_title` on each new painting for image resolution.
|
||||
|
||||
```bash
|
||||
npm run expand-catalog # DB rows only
|
||||
npm run expand-catalog -- --fetch-images # also download images (very slow)
|
||||
npm run dev:expand-catalog # DB rows only
|
||||
npm run dev:expand-catalog -- --fetch-images # also download images (very slow)
|
||||
```
|
||||
|
||||
To add more works, append entries to `famous-paintings-data.js`:
|
||||
@@ -111,36 +147,85 @@ To add more works, append entries to `famous-paintings-data.js`:
|
||||
|
||||
`wikipedia_title` is optional; it defaults to `title`. Use it when the Wikipedia article name differs from the display title.
|
||||
|
||||
Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael, Dürer) are usually above the minimum already; expansion targets Impressionists, modernists, and other artists who had only a single seed painting.
|
||||
Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael, Dürer) are usually above the minimum already when **`sync-image-paths`** has imported files from disk; expansion targets Impressionists, modernists, and other artists who had only a single seed painting.
|
||||
|
||||
## Importing paintings from disk
|
||||
|
||||
When the repository includes a full `data/images/paintings/` tree but the database was seeded fresh (one row per artist), run:
|
||||
|
||||
```bash
|
||||
npm run dev:sync-image-paths
|
||||
```
|
||||
|
||||
`scripts/sync-image-paths.js`:
|
||||
|
||||
1. Scans `data/images/paintings/` for full-size files (not `thumbs/`).
|
||||
2. Matches filenames to artists using the same `{Artist}_{Title}` sanitisation as `server/image-service.js`.
|
||||
3. **Updates** `image_path` / `thumbnail_path` on existing rows when files are found.
|
||||
4. **Inserts** missing painting rows for files not yet in the catalog.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--dry-run` — report counts only, no DB writes.
|
||||
|
||||
Safe to re-run; already-imported works are skipped by normalized title matching.
|
||||
|
||||
Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk files.
|
||||
|
||||
## Artist portraits
|
||||
|
||||
Timeline movement flow loads **`portrait_thumb_path`** (~256px JPEG under `portraits/thumbs/{Artist}_thumb.jpg`) when available; biography and 3D exit navigation use full `portrait_path`. After adding portraits, run `npm run dev:regenerate-portrait-thumbs` to backfill thumbs on dev.
|
||||
|
||||
`npm run dev:fetch-artist-images` runs `scripts/fetch-artist-images.js`:
|
||||
|
||||
1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists.
|
||||
2. Otherwise downloads from Wikipedia / search fallbacks via `image-fetcher.js`.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--force` — re-fetch even when `portrait_path` is already set.
|
||||
- `--limit=N` — process only the first N artists needing portraits.
|
||||
|
||||
Run after seed when portrait files exist on disk but the DB still has null `portrait_path` values.
|
||||
|
||||
## Painting influence graph
|
||||
|
||||
Directed influence links are stored in **`painting_influence_sources`**. Each row connects a painting to a **source** of type `painting`, `artist`, or `movement`, with optional period context (e.g. influence during the work’s creation year).
|
||||
|
||||
The legacy **`painting_influences`** table (painting-to-painting only) is still written for hall navigation compatibility and is backfilled into `painting_influence_sources` on migration.
|
||||
The legacy **`painting_influences`** table (painting-to-painting only) is still written alongside sources when running `npm run dev:update-influences` — it keeps script compatibility and matches the backfill migration. **The API reads only `painting_influence_sources`**, so each edge appears once in the UI.
|
||||
|
||||
Influence data drives:
|
||||
|
||||
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels: painting thumbnails, artist portraits, or movement colour swatches, plus notes, aspects, period labels, and citations
|
||||
- **3D hall exit** — predecessor and successor artists grouped by movement (painting edges only)
|
||||
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
|
||||
- **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); rendered upside down in `InfluencePictureLamp`
|
||||
|
||||
### Audit duplicate influence links
|
||||
|
||||
Painting-to-painting edges exist in both tables by design. To confirm the database has no stray duplicates and that the API model is clean:
|
||||
|
||||
```bash
|
||||
npm run dev:audit-influence-duplicates
|
||||
```
|
||||
|
||||
Reports: edges present in both tables, duplicate rows within either table (should be 0), and legacy-only / sources-only mismatches. If *Influenced* ever shows the same successor twice, restart the server after pulling API fixes — responses must not union legacy and sources tables.
|
||||
|
||||
### One-time migration
|
||||
|
||||
```bash
|
||||
npm run migrate:influence-sources # create table + backfill legacy painting edges
|
||||
npm run dev:migrate:influence-sources # create table + backfill legacy painting edges
|
||||
```
|
||||
|
||||
### Curated updates
|
||||
|
||||
`npm run update-influences` runs `scripts/update-influences.js` against `scripts/art-influences-data.js`:
|
||||
`npm run dev:update-influences` runs `scripts/update-influences.js` against `scripts/art-influences-data.js`:
|
||||
|
||||
```bash
|
||||
npm run update-influences # insert curated edges
|
||||
npm run update-influences -- --fetch-images # also download images for newly created works
|
||||
npm run update-influences -- --discover # curated + web discovery pass
|
||||
npm run discover-influences # discovery only (no curated file pass)
|
||||
npm run update-influences -- --discover --limit=20 # cap discovery to N works
|
||||
npm run dev:update-influences # insert curated edges
|
||||
npm run dev:update-influences -- --fetch-images # also download images for newly created works
|
||||
npm run dev:update-influences -- --discover # curated + web discovery pass
|
||||
npm run dev:discover-influences # discovery only (no curated file pass)
|
||||
npm run dev:update-influences -- --discover --limit=20 # cap discovery to N works
|
||||
```
|
||||
|
||||
Each entry defines a later `work` and one or more `influencedBy` sources. Legacy single-object form is still supported:
|
||||
@@ -191,6 +276,14 @@ Separate from the painting influence graph, `client/src/data/movement-lineage.ts
|
||||
|
||||
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
|
||||
|
||||
## 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 (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; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
|
||||
|
||||
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
|
||||
|
||||
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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
|
||||
## Historical event markers (frontend timeline)
|
||||
|
||||
`client/src/data/historical-events.ts` lists **world-history** markers shown on `Timeline.tsx` (French Revolution, World War I/II, etc.).
|
||||
@@ -200,12 +293,43 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
||||
| Storage | TypeScript module in the client — **not** a database table |
|
||||
| Format | `{ id, name, startYear, endYear?, shortLabel? }` — omit `endYear` for a single-year pin |
|
||||
| Interaction | Click a marker to zoom the shared timeline/movement view to that period |
|
||||
| Year axis labels | Dynamic density in `Timeline.tsx` via `chooseTimelineTickInterval()` — fewer labels when zoomed out |
|
||||
| Pan/zoom batching | `createViewChangeScheduler()` in `timelineView.ts` — one React update per animation frame |
|
||||
| Vertical guides | `TimelineEventGuides.tsx` draws faint gold lines (or shaded spans) from the marker row down through the movement flow, aligned to the same year scale |
|
||||
|
||||
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 art-history citations on the painting detail page — separate from the influence graph and from `curator_notes`.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| Storage | PostgreSQL table `painting_annotations` |
|
||||
| UI | `PaintingAnnotations.tsx` — numbered markers on the image (when `pos_x` / `pos_y` set) plus an “Art history notes” list |
|
||||
| API | Included as `annotations[]` on `GET /api/paintings/:id` |
|
||||
| Curated data | `scripts/painting-annotations-data.js` — artist/title keys matched via `influence-resolver.js` |
|
||||
| Load | `npm run dev:update-painting-annotations` (replaces existing rows per painting by default) |
|
||||
| Wikipedia pass | `npm run dev:update-painting-annotations -- --wikipedia` — one intro sentence per work from `wikipedia_title`; use `--wiki-delay=3000` if rate-limited; `--no-replace` to append without clearing curated rows |
|
||||
|
||||
Categories include `subject`, `technique`, `context`, and `symbolism`. Sources cite Gombrich, museum catalogs, and Wikipedia as appropriate.
|
||||
|
||||
## Batch image fetch
|
||||
|
||||
`npm run fetch-images` (alias: `npm run search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files:
|
||||
`npm run dev:fetch-images` (alias: `npm run dev:search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files:
|
||||
|
||||
| Source | Notes |
|
||||
|--------|--------|
|
||||
@@ -223,13 +347,13 @@ Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
|
||||
| Harvard Art Museums | Optional (`HARVARD_ART_API_KEY` in `.env`) |
|
||||
|
||||
```bash
|
||||
npm run fetch-images # all missing, catalog order (~hours)
|
||||
npm run fetch-images -- --limit=50 # random sample of 50; 10s max per painting
|
||||
npm run fetch-images -- --limit=250 # random sample up to N (caps at current missing count)
|
||||
npm run fetch-images -- --limit=50 --max-wait=120 # slower, more thorough lookup per painting
|
||||
npm run fetch-images -- --artist="Albrecht Dürer" # one artist, catalog order
|
||||
npm run fetch-images -- --discover-only --limit=20 # fix wikipedia_title only
|
||||
npm run fetch-images -- --web-search-only --limit=50 # DuckDuckGo + Commons + multilingual Wikipedia
|
||||
npm run dev:fetch-images # all missing, catalog order (~hours)
|
||||
npm run dev:fetch-images -- --limit=50 # random sample of 50; 10s max per painting
|
||||
npm run dev:fetch-images -- --limit=250 # random sample up to N (caps at current missing count)
|
||||
npm run dev:fetch-images -- --limit=50 --max-wait=120 # slower, more thorough lookup per painting
|
||||
npm run dev:fetch-images -- --artist="Albrecht Dürer" # one artist, catalog order
|
||||
npm run dev:fetch-images -- --discover-only --limit=20 # fix wikipedia_title only
|
||||
npm run dev:fetch-images -- --web-search-only --limit=50 # DuckDuckGo + Commons + multilingual Wikipedia
|
||||
```
|
||||
|
||||
Each run prints **`Missing local files: N`** at startup — that is the current count of catalogued paintings with no full-size or thumbnail file under `data/images/`. A painting counts as present if **either** file exists on disk.
|
||||
@@ -257,15 +381,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` runs **local-only** linking — no network. Call this when entering an artist’s 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -283,10 +409,57 @@ These live in `client/public/` (and `client/dist/` after build).
|
||||
|
||||
1. Insert rows into `artists`, `artist_periods`, `paintings` (or extend the seed script).
|
||||
2. Place image files under `data/images/` using the naming convention.
|
||||
3. Run `npm run fetch-artist-bios` for the new artist’s biography.
|
||||
4. Add entries to `famous-paintings-data.js` and run `npm run expand-catalog` if needed.
|
||||
5. Run `npm run fetch-images -- --artist="…"` or rely on preload / on-demand sync.
|
||||
6. Add influence rows to `painting_influences` with citation fields where possible.
|
||||
3. Run `npm run dev:fetch-artist-bios` for the new artist’s biography.
|
||||
4. Add entries to `famous-paintings-data.js` and run `npm run dev:expand-catalog` if needed.
|
||||
5. Run `npm run dev:fetch-images -- --artist="…"` or rely on preload / on-demand sync.
|
||||
6. Add influence rows via `npm run dev:update-influences` / `art-influences-data.js` (writes both `painting_influence_sources` and legacy painting edges).
|
||||
|
||||
## PainterPalette external dataset
|
||||
|
||||
`Inputs/PainterPalette.csv` is a curated dataset (~10,000 painters) merging WikiArt, Art500k, and Wikidata with cleaned influence fields. The gallery integrates it **for existing DB artists only** (not a full catalog import).
|
||||
|
||||
### One-time setup
|
||||
|
||||
```bash
|
||||
npm run dev:migrate:artist-palette # adds artists.palette_metadata JSONB
|
||||
npm run dev:import-painter-palette # enrich + influence links
|
||||
```
|
||||
|
||||
### What gets imported
|
||||
|
||||
| PainterPalette column | Gallery use |
|
||||
|-----------------------|-------------|
|
||||
| Nationality, gender, styles, birth/death places, occupations, … | Stored in `artists.palette_metadata` |
|
||||
| `birth_year`, `death_year` | Fills missing DB years only |
|
||||
| `Influencedby`, `Teachers` | **Artist** or **movement** sources on that artist's paintings |
|
||||
| `Influencedon`, `Pupils` | Reverse **artist** sources on the pupil/successor's paintings |
|
||||
|
||||
Influence rows are written to `painting_influence_sources` with `discovered_via = painter-palette` and do not duplicate curated Gombrich edges (unique index + `ON CONFLICT DO NOTHING`).
|
||||
|
||||
Name matching uses normalized strings plus aliases in `scripts/painter-palette-lib.js` (e.g. `Bronzino` → `Agnolo Bronzino`, `J. M. W. Turner` → `J.M.W. Turner`). Museum names, WikiArt tags, and dimension strings are filtered out.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run dev:analyze-painter-palette # match report
|
||||
npm run dev:import-painter-palette -- --dry-run
|
||||
npm run dev:import-painter-palette -- --metadata-only
|
||||
npm run dev:import-painter-palette -- --influences-only
|
||||
```
|
||||
|
||||
Re-run `import-painter-palette` after adding gallery artists or updating the CSV; existing palette influence rows are skipped if already present.
|
||||
|
||||
## Catalog export
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
@@ -315,7 +488,7 @@ The catalog can contain the same work more than once — usually from a **double
|
||||
### Find duplicates
|
||||
|
||||
```bash
|
||||
npm run find-duplicates
|
||||
npm run dev:find-duplicates
|
||||
```
|
||||
|
||||
Runs `scripts/find-duplicate-paintings.js`, which reports:
|
||||
@@ -328,7 +501,7 @@ Runs `scripts/find-duplicate-paintings.js`, which reports:
|
||||
|
||||
As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review.
|
||||
|
||||
**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`).
|
||||
**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`). For confirmed duplicates, debug **Remove entry** on painting detail is faster than manual SQL; it deletes files and the row in one step.
|
||||
|
||||
`expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently.
|
||||
|
||||
@@ -336,20 +509,32 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab
|
||||
|
||||
When **Debug mode** is on (home header) or from the **Checkup** page:
|
||||
|
||||
1. **Search** — `GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`).
|
||||
2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets `checkup_fixed` + `checkup_checked`.
|
||||
**Show more** (home header checkbox, `client/src/utils/debugMode.ts`) — when debug mode is on, automatically opens the **More** modal on each painting detail or artist bio page load (same as clicking **More**).
|
||||
|
||||
### Painting detail debug panel
|
||||
1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`).
|
||||
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**. 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.
|
||||
|
||||
With debug mode on, `PaintingDetail.tsx` shows a bottom-left panel with search preview and two buttons:
|
||||
### Debug panel (painting detail and artist bio)
|
||||
|
||||
| Button | API | Effect |
|
||||
|--------|-----|--------|
|
||||
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold |
|
||||
| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery |
|
||||
With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and action buttons:
|
||||
|
||||
The client passes `searchUrl`, `source`, and `thumbUrl` from the search result to improve download reliability. After a fix, `HomePage` updates the gallery session and appends a revision query on 3D texture URLs so replaced files reload even when the path is unchanged.
|
||||
| Button | API (paintings / portraits) | Effect |
|
||||
|--------|----------------------------|--------|
|
||||
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) |
|
||||
| **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 (`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. 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.
|
||||
|
||||
**Curator login required** for all debug/checkup UI and mutating API routes. Guests can browse and enter 3D halls normally; preload remains public. See [API.md — Authentication](API.md#authentication).
|
||||
|
||||
See [API.md](API.md#developer-image-audit) and [basics.md](basics.md#developer-tools-image-audit).
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# Deploy dev → prod (release runbook)
|
||||
|
||||
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).
|
||||
>
|
||||
> **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) |
|
||||
|---|---|---|
|
||||
| Database | `gallery_dev` | `gallery_prod` |
|
||||
| Images | repo `data/images/` | `/mnt/BasePool/Applications/Gallery/data/images` (SMB share **`Gallery`**) |
|
||||
| App | `npm run dev:web` on dev PC `:5173` | `gallery-web` container on TrueNAS `:5173` |
|
||||
| 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 (`T:\Repo\Gallery`) unless a step says **TrueNAS**.
|
||||
|
||||
---
|
||||
|
||||
## One-command release (automated)
|
||||
|
||||
For a typical weekly release, use the orchestrator instead of running each step manually. It reads settings from a local config file, runs the enabled steps in order, prints a **SUCCESS/FAILED** banner at the end of each sub-command, and stops on the first failure.
|
||||
|
||||
### Setup (once)
|
||||
|
||||
1. Copy the example config:
|
||||
|
||||
```powershell
|
||||
Copy-Item infra/deploy/devtoprod.config.example.json infra/deploy/devtoprod.config.json
|
||||
```
|
||||
|
||||
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
|
||||
- `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: **`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
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:release
|
||||
```
|
||||
|
||||
Or double-click [`deploy-dev-to-prod.cmd`](../deploy-dev-to-prod.cmd) in the repo root.
|
||||
|
||||
Dry-run (print steps only, no changes):
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:release -- -DryRun
|
||||
```
|
||||
|
||||
### What the orchestrator does
|
||||
|
||||
| Config step | Maps to runbook |
|
||||
|-------------|-----------------|
|
||||
| `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` (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` (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` |
|
||||
|
||||
Final output is always a release banner:
|
||||
|
||||
```
|
||||
================================================================
|
||||
RELEASE SUCCEEDED - production promoted
|
||||
Steps: validateBuild, gitCommitPush, ...
|
||||
Next: open https://gallery.mysuperlab.netcraze.pro/
|
||||
================================================================
|
||||
```
|
||||
|
||||
Use the manual steps below when you need a **partial** release or want to inspect each step individually.
|
||||
|
||||
---
|
||||
|
||||
## 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 **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 → **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 |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (per release)
|
||||
|
||||
- Prod already installed once on TrueNAS (`gallery-web` app exists).
|
||||
- **Docker Desktop** running on the dev PC; logged in: `docker login gitea.mysuperlab.netcraze.pro`.
|
||||
- Prod env file present: [`infra/docker/.env.prod`](../infra/docker/.env.prod) with `DB_NAME=gallery_prod` (copy from [`.env.prod.example`](../infra/docker/.env.prod.example) if missing).
|
||||
- SMB share mapped for image sync (per Windows session):
|
||||
|
||||
```powershell
|
||||
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Validate on dev
|
||||
|
||||
**Where:** Dev PC + browser
|
||||
|
||||
1. Make sure the change works on https://devgallery.mysuperlab.netcraze.pro (or `http://localhost:5173`).
|
||||
2. Build the client to catch type/compile errors before shipping:
|
||||
|
||||
```powershell
|
||||
npm run prod:build
|
||||
```
|
||||
|
||||
3. Sanity-check the dev API:
|
||||
|
||||
```powershell
|
||||
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
|
||||
```
|
||||
|
||||
Do not proceed until dev is correct — prod is a copy of dev.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Commit and push code
|
||||
|
||||
**Where:** Dev PC
|
||||
|
||||
Commit the release so prod is reproducible and the Docker build ships the intended code.
|
||||
|
||||
```powershell
|
||||
git status
|
||||
git add .
|
||||
git commit -m "Release: <summary of changes>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Regenerate thumbnails on dev (before backup / image sync)
|
||||
|
||||
**Where:** Dev PC
|
||||
|
||||
Rebuilds thumbnail **files** under `data/images/` and updates **`thumbnail_path`** / **`portrait_thumb_path`** in **`gallery_dev`**. Run this on dev **before** backing up the database and syncing images so prod receives both the files and the correct DB paths.
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:thumbnails
|
||||
```
|
||||
|
||||
This runs, in order:
|
||||
|
||||
| Command | Output | DB column |
|
||||
|---------|--------|-----------|
|
||||
| `dev:regenerate-thumbnails` | `data/images/paintings/thumbs/*_thumb.jpg` | `paintings.thumbnail_path` |
|
||||
| `dev:regenerate-portrait-thumbs` | `data/images/portraits/thumbs/*_thumb.jpg` | `artists.portrait_thumb_path` |
|
||||
|
||||
Skip when no painting or portrait images changed since the last release and thumbs are already up to date.
|
||||
|
||||
**Curator debug fixes on dev** already regenerate thumbnails per work — no extra step for individual edits:
|
||||
|
||||
| Debug action | Thumbnail regenerated |
|
||||
|--------------|----------------------|
|
||||
| **Fix it** / **More** / **Upload** on painting detail or Checkup | `paintings/thumbs/{Artist}_{Title}_thumb.jpg` (~400px) + `thumbnail_path` in DB |
|
||||
| **Fix it** / **More** / **Upload** on artist bio | `portraits/thumbs/{Artist}_thumb.jpg` (256px) + `portrait_thumb_path` in DB |
|
||||
|
||||
Use `devtoprod:thumbnails` for **bulk** backfill (missing thumbs, imports, or releases where images changed outside the debug panel).
|
||||
|
||||
> **Order matters:** thumbnail generation updates dev DB paths. If you regenerate **after** Step 3 (backup), run a **fresh backup** (Step 3 again) before Step 5 (restore), or prod will point at thumb paths that were not in the backup.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Back up the dev database
|
||||
|
||||
**Where:** Dev PC
|
||||
|
||||
Creates a data-only dump (INSERT statements) under `db/DataBackup/`.
|
||||
|
||||
```powershell
|
||||
npm run dev:db:backup
|
||||
```
|
||||
|
||||
Output (note the exact path — you pass it to Step 5):
|
||||
|
||||
```
|
||||
Backup written: db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.txt
|
||||
Archive written: db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.zip
|
||||
```
|
||||
|
||||
> Also back up prod before overwriting it (optional but recommended): `npm run prod:db:backup` → `db/DataBackup/gallery_prod_data_*.txt`. Keep it as a rollback point (see [Rollback](#rollback)).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Apply schema changes to prod (only if schema changed)
|
||||
|
||||
**Where:** Dev PC
|
||||
|
||||
The data restore in Step 5 only inserts rows — it does **not** create tables or add columns. If this release added a migration (`db/migrate-*.sql`) or changed `db/schema.sql`, the prod schema must be updated **first** so the new columns/tables exist.
|
||||
|
||||
`server/migrate.js` is idempotent (schema and migrations use `IF NOT EXISTS` / additive `ALTER`s). Point it at prod for one run by overriding `DB_NAME` (all other credentials come from `.env`; the `gallery` user has rights to both databases):
|
||||
|
||||
```powershell
|
||||
$env:DB_NAME = "gallery_prod"
|
||||
npm run dev:migrate
|
||||
Remove-Item Env:\DB_NAME # restore dev default so later commands hit gallery_dev
|
||||
```
|
||||
|
||||
Expect `schema.sql` + each `migrate-*.sql` to log `OK`. Re-running is safe.
|
||||
|
||||
> If you prefer not to override env, temporarily set `DB_NAME=gallery_prod` in a scratch shell only — **never** edit the committed root `.env` to point at prod.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Restore dev data into prod (only if data/DB changed)
|
||||
|
||||
**Where:** Dev PC
|
||||
|
||||
Loads the Step 3 backup into `gallery_prod`. This **TRUNCATES all public tables** in prod first, then inserts dev's rows — prod becomes an exact copy of dev's data.
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.txt
|
||||
```
|
||||
|
||||
Type `yes` at the confirmation prompt (or set `CONFIRM_PROD=1` to skip it in automation).
|
||||
|
||||
The restore loads rows in two ways automatically:
|
||||
|
||||
- **Fast path** — if the DB role may set `session_replication_role` (superuser, or a PG 15+ `GRANT SET ON PARAMETER`), foreign-key checks are disabled for a single-pass load.
|
||||
- **Multi-pass fallback** — the `gallery` role is **not** a superuser, so you will see `note: ... using multi-pass insert`. Rows whose foreign keys are not yet satisfied are retried on later passes until everything loads. No action needed.
|
||||
|
||||
Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole statements, so long text restores correctly.
|
||||
|
||||
**Caveats — prod catalog tables are replaced by dev's contents:**
|
||||
|
||||
- **`users`**, **`session`**, and **`curator_audit_log`** are **not** truncated or loaded from the backup. Prod staff accounts, passwords, active sessions, and audit history stay as they are on `gallery_prod`.
|
||||
- Catalog / content tables (`artists`, `paintings`, tours, translations, etc.) are fully replaced by the dev dump.
|
||||
- After load, serial sequences are reset to `MAX(id)` so new rows (including staff users) do not collide with restored ids.
|
||||
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
|
||||
|
||||
> **Optional** — to use the faster single-pass load, have the postgres superuser run this once in pgAdmin (role-global, covers dev and prod): `GRANT SET ON PARAMETER session_replication_role TO gallery;`
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Sync images to prod (only if image files changed)
|
||||
|
||||
**Where:** Dev PC (SMB share mapped — see Prerequisites)
|
||||
|
||||
Copies `data/images/` (paintings, portraits, `paintings/thumbs/`, and `portraits/thumbs/`) from the repo to the TrueNAS volume.
|
||||
|
||||
**Map the share in this Windows session first** (a fresh terminal has no mapping — robocopy will fail with `ERROR 5 Access is denied` otherwise):
|
||||
|
||||
```powershell
|
||||
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER # prompts for password
|
||||
Test-Path "\\192.168.10.122\Gallery" # must print True
|
||||
```
|
||||
|
||||
Then sync:
|
||||
|
||||
```powershell
|
||||
npm run devtoprod:images
|
||||
```
|
||||
|
||||
Type `yes` when prompted. Robocopy exit codes **0–7** = success. Destination: `\\192.168.10.122\Gallery\data\images`.
|
||||
|
||||
Alternatively, let the script map the share by exporting credentials first: `$env:SMB_USER="…"; $env:SMB_PASSWORD="…"` before running.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Build and push the Docker image (only if code changed)
|
||||
|
||||
**Where:** Dev PC — **PowerShell as Administrator**, Docker Desktop running
|
||||
|
||||
Builds the Express API + freshly built client SPA and pushes to Gitea.
|
||||
|
||||
```powershell
|
||||
docker login gitea.mysuperlab.netcraze.pro
|
||||
npm run prod:docker:publish
|
||||
```
|
||||
|
||||
- Already built and only need to push: `npm run prod:docker:push-only`
|
||||
- Image tag: `gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest`
|
||||
- No registry / offline: `.\infra\docker\save-for-truenas.ps1` → copy `gallery-web.tar` via the `Gallery` SMB share → on TrueNAS `sudo bash infra/docker/truenas-load-image.sh /path/to/gallery-web.tar`.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Restart gallery-web on TrueNAS (after code or schema changes)
|
||||
|
||||
**Where:** TrueNAS — Web UI
|
||||
|
||||
1. **Apps** → **gallery-web** → **Restart** (pulls `:latest` when `pull_policy: always`, otherwise redeploy to force a pull).
|
||||
2. Wait for status **Running**.
|
||||
|
||||
Data-only or image-only releases (no new image) do not require a restart, but a restart is harmless.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Verify production
|
||||
|
||||
**Where:** Dev PC / browser
|
||||
|
||||
Bypass the router first (should return JSON instantly), then check the public URL:
|
||||
|
||||
```powershell
|
||||
curl.exe -s http://192.168.10.122:5173/api/bounds
|
||||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||||
```
|
||||
|
||||
Then open **https://gallery.mysuperlab.netcraze.pro/** and confirm:
|
||||
|
||||
- Timeline + movement flow load; portraits appear.
|
||||
- Click a portrait / movement → 3D hall renders with painting images (`/images/paintings/...`).
|
||||
- **Curator login** (top-right) works with the intended credentials.
|
||||
|
||||
Optional on TrueNAS shell: `bash infra/docker/truenas-verify.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
| Problem | Action |
|
||||
|---------|--------|
|
||||
| Bad **data** release | Re-run Step 5 with the pre-release prod backup: `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_prod_data_<pre-release>.txt` |
|
||||
| Bad **code** release | Rebuild from the last good commit: `git checkout <good-commit>`, `npm run prod:docker:publish`, restart `gallery-web` |
|
||||
| Bad **images** | Re-sync from a known-good dev copy, or pull prod back to dev with `npm run prodto:dev:images` to compare |
|
||||
|
||||
Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data rollback is always possible.
|
||||
|
||||
---
|
||||
|
||||
## Command quick reference
|
||||
|
||||
| Step | Command | Runs on |
|
||||
|------|---------|---------|
|
||||
| **All (config-driven)** | `npm run devtoprod:release` or `deploy-dev-to-prod.cmd` | Dev PC |
|
||||
| 0 Validate | `npm run prod:build` | Dev PC |
|
||||
| 1 Ship code | `git add . ; git commit -m "…" ; git push origin main` | Dev PC |
|
||||
| 2 Thumbnails | `npm run devtoprod:thumbnails` | Dev PC |
|
||||
| 3 Backup dev | `npm run dev:db:backup` | Dev PC |
|
||||
| 3 Backup prod (rollback point) | `npm run prod:db:backup` | Dev PC |
|
||||
| 4 Migrate prod schema | `$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME` | Dev PC |
|
||||
| 5 Restore data → prod | `npm run devtoprod:db:restore -- --file <dev backup>.txt` | Dev PC |
|
||||
| 6 Sync images → prod | `npm run devtoprod:images` | Dev PC (SMB) |
|
||||
| 7 Build + push image | `npm run prod:docker:publish` | Dev PC (Admin) |
|
||||
| 8 Restart app | Apps → gallery-web → Restart | TrueNAS UI |
|
||||
| 9 Verify | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` | Dev PC |
|
||||
|
||||
---
|
||||
|
||||
## Safety guards (built in)
|
||||
|
||||
- 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
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `/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 |
|
||||
| Timeline portraits slow or missing on prod | Thumbs not regenerated or not synced | Run Step 2 on dev, then Step 3 (fresh backup), Step 5, and Step 6 |
|
||||
| `502 / 504` on the public prod URL | Keenetic upstream wrong (IP/port/protocol) | `192.168.10.122:5173`, protocol **http**; verify LAN `curl.exe -s http://192.168.10.122:5173/api/bounds` first |
|
||||
|
||||
## Related docs
|
||||
|
||||
| Document | Contents |
|
||||
|----------|----------|
|
||||
| [environments.md](environments.md) | Full dev/prod walkthrough, one-time setup, Keenetic |
|
||||
| [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) | TrueNAS container install and troubleshooting |
|
||||
| [FAC.md](FAC.md#promote-dev--prod-scheduled-release) | Command cheat sheet |
|
||||
| [data-and-images.md](data-and-images.md) | Catalog and image pipeline |
|
||||
@@ -0,0 +1,366 @@
|
||||
# Development and production environments
|
||||
|
||||
Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two databases**. The **dev PC** (`192.168.10.70`) runs `npm run dev:web` on port **5173**, published at **`https://devgallery.mysuperlab.netcraze.pro`** via Keenetic.
|
||||
|
||||
| Environment | Database | Public URL | App host |
|
||||
|-------------|----------|------------|----------|
|
||||
| **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)
|
||||
|
||||
**All day-to-day work happens on dev.** Code edits, database changes, catalog updates, image fixes, migrations, and testing use **`gallery_dev`**, the repo’s **`data/images/`**, and **https://devgallery.mysuperlab.netcraze.pro** (or `http://localhost:5173` on the dev PC).
|
||||
|
||||
Production is **not** updated on every change. Promote to prod on a **regular cadence** (typically about once a week) or when you explicitly decide to release:
|
||||
|
||||
| Phase | Where | What |
|
||||
|-------|-------|------|
|
||||
| **Daily** | Dev PC + `gallery_dev` | Feature work, fixes, seed/migrate, curator edits, image pipeline |
|
||||
| **Release** | Dev PC → TrueNAS | Backup dev → restore prod DB (if data changed) → sync images → `docker:publish` → restart **gallery-web** |
|
||||
|
||||
Unless a task explicitly says **prod** or **production**, assume the target is **dev** — including when asking an assistant to change, fix, or modify code, data, or the database.
|
||||
|
||||
**Never** point the dev PC `.env` at `gallery_prod`. Prod-only files (`infra/docker/.env.prod`, `db:restore:prod`, `images:sync-to-prod`) are for scheduled releases, not routine development.
|
||||
|
||||
---
|
||||
|
||||
## Where to run what (quick reference)
|
||||
|
||||
| Step | Machine | Interface | Privilege | What |
|
||||
|------|---------|-----------|-----------|------|
|
||||
| Stop dev servers | Dev PC `192.168.10.70` | PowerShell (normal) | your user | Close `dev:web` / `dev:server` / `dev:client` terminals |
|
||||
| **DB split (one-time)** | Dev PC | **pgAdmin** → Query Tool on `postgres` | **postgres superuser** | [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) step by step |
|
||||
| Dev migrate / dev:web | Dev PC | PowerShell (normal) | your user | `npm run dev:migrate`, `npm run dev:web` |
|
||||
| Image dir on TrueNAS | TrueNAS `192.168.10.122` | **Shell** (SSH or UI → System Settings → Shell) | root / sudo | `bash infra/docker/truenas-setup.sh` |
|
||||
| Copy images to prod | Dev PC | PowerShell (normal) | SMB `\\192.168.10.122\Gallery` | `net use` then `npm run devtoprod:images` |
|
||||
| Build + push Docker image | Dev PC | **PowerShell as Administrator** | admin (for LAN hosts entry) | `npm run prod:docker:publish` |
|
||||
| Install prod app | TrueNAS | **Web UI** → Apps → Custom App | admin | Paste `infra/docker/compose.truenas.yaml` |
|
||||
| Verify prod | Dev PC or TrueNAS | PowerShell / browser | any | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` |
|
||||
|
||||
You do **not** need `psql` on TrueNAS. Database work is done from **pgAdmin on the dev PC** connected to `192.168.10.122:5432`.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (full walkthrough)
|
||||
|
||||
### Step A — Stop Gallery on the dev PC
|
||||
|
||||
**Where:** Dev PC — PowerShell or open terminal tabs (no admin needed)
|
||||
|
||||
Close anything using ports **5173**, **3451**, or **3520**:
|
||||
|
||||
- Stop `npm run dev:web`, `npm run dev:server`, `npm run dev:client`
|
||||
- If prod container already runs on TrueNAS: **TrueNAS Web UI** → Apps → **gallery-web** → **Stop**
|
||||
|
||||
### Step B — Split the database (pgAdmin)
|
||||
|
||||
**Where:** Dev PC — **pgAdmin** (not TrueNAS shell)
|
||||
|
||||
1. Open **pgAdmin**.
|
||||
2. Add/connect to server:
|
||||
- **Host:** `192.168.10.122`
|
||||
- **Port:** `5432`
|
||||
- **Maintenance database:** `postgres`
|
||||
- **Username:** `postgres` (superuser — **not** `gallery`)
|
||||
- **Password:** your postgres password
|
||||
3. Tree: **Servers** → your server → **Databases** → click **`postgres`**
|
||||
4. **Tools** → **Query Tool** (or right-click `postgres` → Query Tool)
|
||||
5. **File** → **Open** → `Gallery\db\split-dev-prod-pgadmin.sql`
|
||||
6. Run **each STEP separately** (highlight from `-- STEP N` through that section, press **F5** / Execute):
|
||||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| **STEP 0** | Pre-flight — should show database `Gallery` |
|
||||
| **STEP 1** | Terminate connections |
|
||||
| **STEP 2** | `ALTER DATABASE "Gallery" RENAME TO gallery_prod` (use lowercase variant only if STEP 0 showed `gallery`) |
|
||||
| **STEP 3** | `CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod` (wait ~1–2 min) |
|
||||
| **STEP 4** | `GRANT` to user `gallery` |
|
||||
| **STEP 5** | Verify — should show `gallery_prod` and `gallery_dev` |
|
||||
| **STEP 6** | **New Query Tool on `gallery_dev`** (not postgres!) — `SELECT current_database()` then `SELECT count(*) FROM paintings` |
|
||||
|
||||
If STEP 2 fails with “database already exists”, STEP 0 likely already shows `gallery_prod` — skip STEP 2 and continue from STEP 3 (skip STEP 3 too if `gallery_dev` exists).
|
||||
|
||||
**Alternative (dev PC with psql installed):**
|
||||
|
||||
```powershell
|
||||
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 T:\Repo\Gallery
|
||||
$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD"
|
||||
npm run infra:db:split-dev-prod
|
||||
```
|
||||
|
||||
### Step C — Configure dev PC and test
|
||||
|
||||
**Where:** Dev PC — PowerShell (normal), repo root
|
||||
|
||||
1. Edit `.env` (copy from `.env.example` if needed):
|
||||
|
||||
```env
|
||||
DB_NAME=gallery_dev
|
||||
PORT=3451
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
SESSION_SECRET=your-long-random-secret
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
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. Admins browse curator actions on **Activity** (`/api/audit`), which always reads the DB named by `DB_NAME` for that environment (`gallery_dev` here; `gallery_prod` on prod). **`users`**, **`session`**, and **`curator_audit_log`** are never copied by harmonize or `devtoprod:db:restore` — each env keeps its own staff accounts and audit history.
|
||||
|
||||
2. Run:
|
||||
|
||||
```powershell
|
||||
cd T:\Repo\Gallery
|
||||
npm run dev:migrate
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
3. Open **https://devgallery.mysuperlab.netcraze.pro** (or http://localhost:5173 on the dev PC)
|
||||
|
||||
### Step D — Prepare prod image folder on TrueNAS
|
||||
|
||||
**Where:** TrueNAS — **Shell** (SSH to `192.168.10.122`, or TrueNAS UI → **System Settings** → **Shell**)
|
||||
|
||||
```bash
|
||||
# Copy script to TrueNAS first, or paste commands manually:
|
||||
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits
|
||||
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs
|
||||
chown -R 1001:1001 /mnt/BasePool/Applications/Gallery
|
||||
chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery
|
||||
```
|
||||
|
||||
Or from a checkout on TrueNAS: `bash infra/docker/truenas-setup.sh`
|
||||
|
||||
### Step E — Copy images dev → prod volume
|
||||
|
||||
**Where:** Dev PC — PowerShell (normal), repo root
|
||||
|
||||
Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS.
|
||||
|
||||
**Connect to the share first** (once per Windows session), then sync:
|
||||
|
||||
```powershell
|
||||
# Map share (use your TrueNAS SMB user/password)
|
||||
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
|
||||
|
||||
cd T:\Repo\Gallery
|
||||
npm run devtoprod:images
|
||||
```
|
||||
|
||||
UNC destination: `\\192.168.10.122\Gallery\data\images`
|
||||
|
||||
Type `yes` when prompted. First run copies ~1000+ files (several minutes).
|
||||
|
||||
If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in, then retry.
|
||||
|
||||
### Step F — Build and push Docker image
|
||||
|
||||
**Where:** Dev PC — **PowerShell as Administrator** (for fast LAN push to Gitea)
|
||||
|
||||
Prerequisites: **Docker Desktop running**, logged in to Gitea.
|
||||
|
||||
```powershell
|
||||
cd T:\Repo\Gallery
|
||||
docker login gitea.mysuperlab.netcraze.pro
|
||||
npm run prod:docker:publish
|
||||
```
|
||||
|
||||
Use normal PowerShell with `-SkipHosts` if you already added `192.168.10.122 gitea.mysuperlab.netcraze.pro` to `C:\Windows\System32\drivers\etc\hosts`.
|
||||
|
||||
### Step G — Install production on TrueNAS
|
||||
|
||||
**Where:** TrueNAS — **Web UI** (browser)
|
||||
|
||||
1. **Apps** → **Discover Apps** → **Custom App** → **Install via Docker Compose**
|
||||
2. Paste contents of `infra/docker/compose.truenas.yaml` from the repo
|
||||
3. Replace `YOUR_POSTGRES_PASSWORD` with the `gallery` user password
|
||||
4. Replace `REPLACE_WITH_LONG_RANDOM_SECRET` and `REPLACE_WITH_SECURE_PASSWORD` for `SESSION_SECRET` and `CURATOR_PASSWORD`
|
||||
5. **Apps** → **Settings** → register Gitea registry (`gitea.mysuperlab.netcraze.pro`, token with `read:package`)
|
||||
6. Deploy → wait for **gallery-web** to show **Running**
|
||||
7. Run `npm run dev:migrate` against `gallery_prod` if auth tables are not yet applied (or migrate from dev PC with prod env)
|
||||
|
||||
### Step H — Verify production
|
||||
|
||||
**Where:** Dev PC — PowerShell (normal) or any browser
|
||||
|
||||
```powershell
|
||||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||||
curl.exe -s http://192.168.10.122:5173/api/bounds
|
||||
```
|
||||
|
||||
Open **https://gallery.mysuperlab.netcraze.pro/** — timeline and sample painting images should load. Click an artist portrait or movement label to enter a 3D hall. **Curator login** (top-right) unlocks debug mode and Checkup.
|
||||
|
||||
---
|
||||
|
||||
## Environment files
|
||||
|
||||
| File | Git | Purpose |
|
||||
|------|-----|---------|
|
||||
| [`.env`](../.env) | ignored | **Dev** — `DB_NAME=gallery_dev`, `PORT=3451` |
|
||||
| [`.env.example`](../.env.example) | tracked | Dev template |
|
||||
| [`infra/docker/.env.prod`](../infra/docker/.env.prod) | ignored | **Prod** — migrate/restore prod scripts |
|
||||
| [`infra/docker/.env.prod.example`](../infra/docker/.env.prod.example) | tracked | Prod template |
|
||||
| [`infra/docker/compose.truenas.yaml`](../infra/docker/compose.truenas.yaml) | tracked | TrueNAS Custom App |
|
||||
|
||||
Never point dev `.env` at `gallery_prod`. Prod scripts refuse dev env files.
|
||||
|
||||
---
|
||||
|
||||
## Dev public access (Keenetic)
|
||||
|
||||
**Where:** Keenetic router Web UI (not dev PC)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Domain | `devgallery.mysuperlab.netcraze.pro` |
|
||||
| Upstream IP | `192.168.10.70` |
|
||||
| Upstream port | **`5173`** |
|
||||
| **Protocol to device** | **`http`** (not https) |
|
||||
| Preserve Host | ON |
|
||||
|
||||
Vite on the dev PC speaks **plain HTTP** only. Keenetic terminates HTTPS from the browser, then must forward **HTTP** to `192.168.10.70:5173`.
|
||||
|
||||
If **Protocol to device** is `https`, Keenetic tries TLS against Vite → **502 Bad Gateway** (`Server: Web server`).
|
||||
|
||||
**Verify from dev PC** (servers must be running: `npm run dev:web`):
|
||||
|
||||
```powershell
|
||||
# PowerShell: curl is an alias — use curl.exe for -k, or Invoke-WebRequest
|
||||
curl.exe -s -o NUL -w "HTTP %{http_code}`n" http://192.168.10.70:5173/
|
||||
curl.exe -sk -o NUL -w "HTTP %{http_code}`n" https://devgallery.mysuperlab.netcraze.pro/
|
||||
|
||||
# Or native PowerShell (skip cert check):
|
||||
Invoke-WebRequest -Uri https://devgallery.mysuperlab.netcraze.pro/ -SkipCertificateCheck | Select-Object StatusCode
|
||||
```
|
||||
|
||||
Expect **HTTP 200** (not 502).
|
||||
|
||||
**`.env`:** use the same scheme as the browser URL. If Keenetic serves HTTPS publicly:
|
||||
|
||||
```env
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
TRUST_PROXY=true
|
||||
SESSION_SECRET=your-long-random-secret
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Production public access (Keenetic)
|
||||
|
||||
**Where:** Keenetic router Web UI
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Domain | `gallery.mysuperlab.netcraze.pro` |
|
||||
| Upstream IP | **`192.168.10.122`** (TrueNAS — not the dev PC) |
|
||||
| Upstream port | **`5173`** |
|
||||
| **Protocol to device** | **`http`** (container speaks HTTP, not HTTPS) |
|
||||
| Preserve Host | ON |
|
||||
|
||||
The prod container listens on plain HTTP on port **5173**. Keenetic terminates HTTPS from the browser and must forward **HTTP** to TrueNAS.
|
||||
|
||||
**Verify the app first (bypass Keenetic):**
|
||||
|
||||
```powershell
|
||||
curl.exe -s http://192.168.10.122:5173/api/bounds
|
||||
```
|
||||
|
||||
Expect JSON immediately (~200). If this works but the public URL returns **502/504**, the Keenetic rule is wrong (IP, port, or `https` to device).
|
||||
|
||||
**Then verify public URL:**
|
||||
|
||||
```powershell
|
||||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||||
```
|
||||
|
||||
See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md) — same Keenetic HTTPS → HTTP upstream pattern.
|
||||
|
||||
---
|
||||
|
||||
## Daily development
|
||||
|
||||
**Where:** Dev PC — PowerShell (normal), repo root
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Public dev URL (Keenetic) | `npm run dev:web` |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Promote dev → prod (scheduled release)
|
||||
|
||||
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).
|
||||
|
||||
**Where:** Dev PC unless noted
|
||||
|
||||
1. Finish and test on https://devgallery.mysuperlab.netcraze.pro
|
||||
2. `npm run devtoprod:thumbnails` — skip if no painting/portrait images changed
|
||||
3. `npm run dev:db:backup`
|
||||
4. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (type `yes`) — skip if only code changed and prod DB should stay as-is
|
||||
5. `npm run devtoprod:images` — skip if no new/changed images
|
||||
6. `npm run prod:docker:publish` — required when application code changed
|
||||
7. **TrueNAS Web UI** → restart **gallery-web**
|
||||
8. Verify https://gallery.mysuperlab.netcraze.pro
|
||||
|
||||
---
|
||||
|
||||
## Database scripts
|
||||
|
||||
| Command | Where | Purpose |
|
||||
|---------|-------|---------|
|
||||
| pgAdmin + `split-dev-prod-pgadmin.sql` | Dev PC pgAdmin | One-time split (recommended) |
|
||||
| `npm run infra:db:split-dev-prod` | Dev PC PowerShell | Same split (needs `PGUSER=postgres`) |
|
||||
| `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 |
|
||||
| `npm run prodto:dev:images` | Dev PC PowerShell | TrueNAS → dev repo |
|
||||
|
||||
## Safety guards
|
||||
|
||||
- Prod restore reads only `infra/docker/.env.prod`
|
||||
- Dev backup refuses `_prod` database names without `--prod`
|
||||
- Destructive prod ops require typing `yes` or `CONFIRM_PROD=1`
|
||||
|
||||
## Legacy deployment
|
||||
|
||||
Node on dev PC `:3520` + nginx → Vite `:5173` is optional; see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf). Production should use TrueNAS Docker on `:5173`.
|
||||
|
||||
Deploy details: [`infra/docker/DEPLOY-truenas.md`](../infra/docker/DEPLOY-truenas.md).
|
||||
@@ -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 artist’s** 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).
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
|
||||
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||
|
||||
| Key | Where |
|
||||
|-----|--------|
|
||||
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||
| `captionTreeFlow` | Tree of Art chart |
|
||||
|
||||
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||
---
|
||||
|
||||
## 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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Tree of Art — alternative start page
|
||||
|
||||
An alternative landing layout for the timeline: the year axis runs **bottom → top**
|
||||
and the art movements are drawn as a **growing tree** instead of parallel streams.
|
||||
Reached from the **🌳 Tree of art** link at the top-left of every timeline page
|
||||
(`layoutTree`); the classic and vertical layouts stay untouched and are one click away.
|
||||
Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts).
|
||||
|
||||
| | Classic | Vertical | **Tree** |
|
||||
|---|---|---|---|
|
||||
| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) |
|
||||
| Time axis | left → right | bottom → top | bottom → top |
|
||||
| Layout | temporal lanes | centre-out lanes | spanning tree |
|
||||
| Recomputed on zoom | yes | yes | **no — structure is fixed** |
|
||||
| Artist portraits | yes | no | no |
|
||||
|
||||
Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx)
|
||||
axis, so eras, event marks, zoom and pan behave identically across them.
|
||||
|
||||
## Layout rules
|
||||
|
||||
The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts)
|
||||
decides the **shape of the tree** (horizontal, view-independent), and `MovementTree`
|
||||
maps that shape onto the **current year window** (vertical) each frame.
|
||||
|
||||
### Structure — `buildMovementTree()`
|
||||
|
||||
1. **Time grows upward.** Oldest movements at the bottom, newest at the crown.
|
||||
Y is a plain `year → pixel` mapping; the layout engine never touches it.
|
||||
2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a
|
||||
spanning tree: each movement keeps its **most immediate predecessor** (the parent
|
||||
with the latest start year that still precedes it) as its structural parent.
|
||||
Ranking by start year first makes cycles impossible by construction.
|
||||
3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn —
|
||||
as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism`
|
||||
survives even though Cubism hangs structurally off Fauvism.
|
||||
4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole
|
||||
subtree (`max(own limb, Σ children)`); children are packed side by side and centred
|
||||
on the parent. A single-child chain inherits the parent's x exactly — the trunk stays
|
||||
straight until it forks, forks spread symmetrically, and later generations land
|
||||
further from the centre.
|
||||
5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries:
|
||||
`base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every
|
||||
branch tapers as it rises and sheds children. A movement's *own* thickness comes from
|
||||
its `influence_link_count`.
|
||||
6. **Branches lean outward** across their own lifespan, by at most the slack left inside
|
||||
their slot — organic, and collision-free by construction.
|
||||
7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root;
|
||||
extra roots are planted alternately right and left of the trunk, widest subtree first,
|
||||
so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare.
|
||||
|
||||
Because the structure is built from the **whole catalogue**, zooming never reshuffles the
|
||||
tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view
|
||||
change.
|
||||
|
||||
### Rendering — `MovementTree`
|
||||
|
||||
8. **Spread follows zoom, not the canvas.** 90 % of the catalogue lives in the last 15 %
|
||||
of the time axis, so a tree stretched to full width with all of history in view is one
|
||||
long trunk under a flat bar. The whole-history view draws the tree at
|
||||
`FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the
|
||||
crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart
|
||||
grows as you walk up it. Whatever is on screen is always clamped to fit the canvas.
|
||||
9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900
|
||||
years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at
|
||||
least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker
|
||||
than 55 % of its own length**. Positions still come from real years; only the drawn
|
||||
length and thickness have a floor, and the junction slides down the parent limb (never
|
||||
off it) to find its rise.
|
||||
10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset
|
||||
along the curve *normal*, so a junction stays solid even when a zoomed-out view
|
||||
squeezes it almost flat. Shading runs dark → colour → dark across each limb for a
|
||||
rounded, woody read.
|
||||
11. **Greedy label declutter.** Every visible movement asks for a name; closest to the
|
||||
trunk wins, and names that would collide with a placed one — or fall off the canvas —
|
||||
stay hidden until you zoom in on them.
|
||||
12. **Hover lights the descent line.** Hovering a movement brightens its whole path back
|
||||
to the root (grafts included) and dims the rest — the fastest way to read "where did
|
||||
this come from".
|
||||
|
||||
Clicking any limb or label opens the movement's artist picker and then its 3D movement
|
||||
gallery, exactly as the other two layouts do.
|
||||
|
||||
## Colours and captions
|
||||
|
||||
- Limb fill/shading uses [`utils/movementColor.ts`](../client/src/utils/movementColor.ts)
|
||||
(`vividMovementColor`, `shadeMovementColor`) — the same helpers as the classic and
|
||||
vertical charts. Malformed catalogue hex falls back to the input string; values longer
|
||||
than six digits keep the first six (`#rrggbbaa` → `#rrggbb`).
|
||||
- The chart hint under the canvas is `captionTreeFlow` in
|
||||
`locales/{en,ru}/home.json` (same pattern as the other layouts).
|
||||
|
||||
## Tuning
|
||||
|
||||
All constants sit at the top of the two files and are safe to tune:
|
||||
|
||||
| Constant | File | Effect |
|
||||
|---|---|---|
|
||||
| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit |
|
||||
| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range |
|
||||
| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward |
|
||||
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
|
||||
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
|
||||
|
||||
New lineage edges only need adding to
|
||||
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
|
||||
picks up parents, grafts, thickness and spacing from there automatically.
|
||||
+177
-59
@@ -8,6 +8,8 @@
|
||||
|
||||
## Environment
|
||||
|
||||
**Development-first:** The root `.env` is for **dev only** (`DB_NAME=gallery_dev`). All routine work — code, migrations, seeds, image scripts, curator edits — targets dev. Production uses `infra/docker/.env.prod` and TrueNAS; promote on a scheduled release (~weekly), not after every change. See [environments.md — Development-first workflow](environments.md#development-first-workflow-default).
|
||||
|
||||
Copy the example file and fill in credentials:
|
||||
|
||||
```bash
|
||||
@@ -20,9 +22,18 @@ cp .env.example .env
|
||||
| `DB_PORT` | Port (default `5432`) |
|
||||
| `DB_USER` | Database user |
|
||||
| `DB_PASSWORD` | Database password |
|
||||
| `DB_NAME` | Database name (`Gallery`) |
|
||||
| `PORT` | API listen port (default `3001`) |
|
||||
| `DB_NAME` | Database name (`gallery_dev` for dev; prod uses `gallery_prod`) |
|
||||
| `PORT` | API listen port (`3451` for `dev:web`; prod container uses `5173`) |
|
||||
| `HOST` | Bind address (default `0.0.0.0` — required for LAN access) |
|
||||
| `PUBLIC_URL` | Public URL (dev: `https://devgallery.mysuperlab.netcraze.pro`; prod: `https://gallery.mysuperlab.netcraze.pro`) |
|
||||
| `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` | 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.
|
||||
|
||||
Optional script tuning:
|
||||
|
||||
@@ -31,8 +42,6 @@ Optional script tuning:
|
||||
| `MIN_PAINTINGS` | `expand-catalog` | Minimum paintings per artist (default `6`) |
|
||||
| `FETCH_MAX_WAIT_SEC` | `fetch-images` | Max seconds per painting in batch runs (default `10`) |
|
||||
|
||||
`.env` is git-ignored; never commit passwords.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -45,97 +54,172 @@ cd client && npm install && cd ..
|
||||
One-shot setup (migrate + seed):
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
npm run dev:setup
|
||||
```
|
||||
|
||||
Or step by step:
|
||||
|
||||
```bash
|
||||
npm run migrate # apply db/schema.sql
|
||||
npm run seed # eras, movements, artists, paintings, influences
|
||||
npm run dev:migrate # db/schema.sql + db/migrate-*.sql via server/migrate.js
|
||||
npm run dev:seed # eras, movements, artists, flagship paintings (one per artist)
|
||||
```
|
||||
|
||||
`npm run dev:migrate` is safe to re-run on existing databases (uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`).
|
||||
|
||||
If migration fails with permission errors, grant schema rights to the app user first (see [DB_structure.md](DB_structure.md)).
|
||||
|
||||
### 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. 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, 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).
|
||||
|
||||
### Recommended post-seed steps
|
||||
|
||||
After a fresh seed, run these to match a fully populated local install:
|
||||
|
||||
```bash
|
||||
npm run fetch-artist-bios # bio_short / bio_full from Wikipedia
|
||||
npm run expand-catalog # famous works for artists below MIN_PAINTINGS
|
||||
npm run update-influences # painting influence graph for detail view + hall exits
|
||||
npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page
|
||||
npm run fetch-images -- --limit=50 # random sample; 10s max per painting (default)
|
||||
npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
|
||||
npm run dev:sync-image-paths # import paintings from data/images/paintings/ (clone with image files)
|
||||
npm run dev:fetch-artist-images # link local portraits or download from Wikipedia
|
||||
npm run dev:fetch-artist-bios # bio_short / bio_full from Wikipedia
|
||||
npm run dev:expand-catalog # famous works for artists below MIN_PAINTINGS
|
||||
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)
|
||||
npm run dev:fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
|
||||
cd client && npm run build && cd ..
|
||||
```
|
||||
|
||||
Image fetch can take hours if you run it for the entire catalog. The first line of each run reports **`Missing local files: N`**. Use **`npm run fetch-images -- --limit=N`** for random batches (10s per painting by default), **`--artist="…"`** for one artist in catalog order, or on-demand resolution when viewing a painting in the detail view.
|
||||
**Order matters:** `dev:sync-image-paths` should run when the repo already contains painting files under `data/images/paintings/` but the database only has one flagship work per artist (typical after `npm run dev:setup` on a clone). `dev:update-influences` is required for *Influenced By* / *Influenced* panels and golden lamps in the 3D hall — seed does not insert influence edges.
|
||||
|
||||
Image fetch can take hours if you run it for the entire catalog. The first line of each run reports **`Missing local files: N`**. Use **`npm run dev:fetch-images -- --limit=N`** for random batches (10s per painting by default), **`--artist="…"`** for one artist in catalog order, or on-demand resolution when viewing a painting in the detail view.
|
||||
|
||||
## Run
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run server` | API + static SPA on `PORT` |
|
||||
| `npm run dev:server` | API with nodemon reload |
|
||||
| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) |
|
||||
| `npm run prod:docker:publish` | Build + push prod image to Gitea |
|
||||
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
|
||||
| `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 catalog DB to prod (prod restore skips staff/session/audit; syncs sequences) |
|
||||
| `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 |
|
||||
| `npm run dev` | Alias for `server` |
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd client && npm run build && cd ..
|
||||
npm run server
|
||||
npm run prod:build
|
||||
# or: cd client && npm run build && cd ..
|
||||
npm run dev:start
|
||||
```
|
||||
|
||||
Open http://localhost:3001 (or your configured `PORT`).
|
||||
Open http://localhost:5173 (Vite) or http://localhost:3451 (API only).
|
||||
|
||||
## Production deployment
|
||||
|
||||
Production runs as **`gallery-web`** on TrueNAS at **https://gallery.mysuperlab.netcraze.pro** (Keenetic → `:5173`). Full guide: [environments.md](environments.md) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md).
|
||||
|
||||
| Access | URL |
|
||||
|--------|-----|
|
||||
| Production (Keenetic) | https://gallery.mysuperlab.netcraze.pro |
|
||||
| Development (Keenetic) | https://devgallery.mysuperlab.netcraze.pro |
|
||||
| LAN direct (prod container) | http://192.168.10.122:5173 |
|
||||
| LAN direct (dev PC) | http://192.168.10.70:5173 |
|
||||
|
||||
Quick deploy checklist:
|
||||
|
||||
1. One-time DB split in **pgAdmin** on dev PC: [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql)
|
||||
2. Dev `.env` → `DB_NAME=gallery_dev`, `PORT=3451`, `PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro`, plus auth vars (`SESSION_SECRET`, `CURATOR_*`)
|
||||
3. `npm run dev:migrate` on dev and prod DBs (includes auth tables + bootstrap curator)
|
||||
4. `net use \\192.168.10.122\Gallery` → `npm run devtoprod:images`
|
||||
5. `npm run prod:docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml` (set auth env in compose)
|
||||
6. Keenetic: both domains → `:5173`, protocol to device **`http`**, correct IP per environment
|
||||
|
||||
### Legacy deployment (optional)
|
||||
|
||||
Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueNAS Docker prod and `npm run dev:web` for public dev. See [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) and [`deploy/gallery.service`](../deploy/gallery.service) only if you need a local nginx/systemd setup.
|
||||
|
||||
## Maintenance scripts
|
||||
|
||||
| Command | Script | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `npm run seed` | `scripts/seed-wikipedia.js` | Reload curated Wikipedia data |
|
||||
| `npm run fetch-artist-bios` | `scripts/fetch-artist-bios.js` | Wikipedia lead sections → `bio_short` / `bio_full` |
|
||||
| `npm run fetch-artist-bios -- --force` | ↑ | Refresh bios even when already set |
|
||||
| `npm run expand-catalog` | `scripts/expand-paintings.js` | Insert famous works from `famous-paintings-data.js` |
|
||||
| `npm run expand-catalog -- --fetch-images` | ↑ | Also download images for new rows (slow) |
|
||||
| `npm run fetch-images` | `scripts/fetch-missing-images.js` | Search Wikipedia, Commons, museums for missing files |
|
||||
| `npm run search-missing-paintings` | ↑ (alias) | Same as `fetch-images` |
|
||||
| `npm run fetch-images -- --artist="Name"` | ↑ | Limit to one artist (catalog order) |
|
||||
| `npm run fetch-images -- --limit=50` | ↑ | Random sample of N missing paintings |
|
||||
| `npm run fetch-images -- --limit=250` | ↑ | Larger random batch (processes min(N, missing count)) |
|
||||
| `npm run fetch-images -- --limit=50 --max-wait=120` | ↑ | Random batch; 120s cap per painting |
|
||||
| `npm run fetch-images -- --discover-only --limit=20` | ↑ | Fix `wikipedia_title` via search only |
|
||||
| `npm run regenerate-thumbnails` | `scripts/regenerate-thumbnails.js` | Rebuild all thumbs from full local files |
|
||||
| `npm run audit-painting-images` | `scripts/audit-painting-images.js` | List thumb/full aspect-ratio mismatches |
|
||||
| `npm run migrate:thumbnails` | `db/migrate-thumbnails.sql` | Add thumbnail columns |
|
||||
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* |
|
||||
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
|
||||
| `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
|
||||
| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
|
||||
| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
|
||||
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
|
||||
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
|
||||
| `npm run update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) |
|
||||
| `npm run discover-influences` | `update-influences.js --discover-only` | Discovery pass only |
|
||||
| `npm run dev:seed` | `scripts/seed-wikipedia.js` | Reload curated Wikipedia data |
|
||||
| `npm run dev:fetch-artist-bios` | `scripts/fetch-artist-bios.js` | Wikipedia lead sections → `bio_short` / `bio_full` |
|
||||
| `npm run dev:fetch-artist-bios -- --force` | ↑ | Refresh bios even when already set |
|
||||
| `npm run dev:expand-catalog` | `scripts/expand-paintings.js` | Insert famous works from `famous-paintings-data.js` |
|
||||
| `npm run dev:expand-catalog -- --fetch-images` | ↑ | Also download images for new rows (slow) |
|
||||
| `npm run dev:fetch-images` | `scripts/fetch-missing-images.js` | Search Wikipedia, Commons, museums for missing files |
|
||||
| `npm run dev:search-missing-paintings` | ↑ (alias) | Same as `fetch-images` |
|
||||
| `npm run dev:fetch-images -- --artist="Name"` | ↑ | Limit to one artist (catalog order) |
|
||||
| `npm run dev:fetch-images -- --limit=50` | ↑ | Random sample of N missing paintings |
|
||||
| `npm run dev:fetch-images -- --limit=250` | ↑ | Larger random batch (processes min(N, missing count)) |
|
||||
| `npm run dev:fetch-images -- --limit=50 --max-wait=120` | ↑ | Random batch; 120s cap per painting |
|
||||
| `npm run dev:fetch-images -- --discover-only --limit=20` | ↑ | Fix `wikipedia_title` via search only |
|
||||
| `npm run dev:regenerate-thumbnails` | `scripts/regenerate-thumbnails.js` | Rebuild all thumbs from full local files |
|
||||
| `npm run dev:audit-painting-images` | `scripts/audit-painting-images.js` | List thumb/full aspect-ratio mismatches |
|
||||
| `npm run dev:migrate:thumbnails` | `db/migrate-thumbnails.sql` | Add thumbnail columns |
|
||||
| `npm run dev:fetch-artist-images` | `scripts/fetch-artist-images.js` | Download portraits or link existing files under `data/images/portraits/` |
|
||||
| `npm run dev:fetch-artist-images -- --force` | ↑ | Re-fetch even when `portrait_path` is set |
|
||||
| `npm run dev:sync-image-paths` | `scripts/sync-image-paths.js` | Link `image_path` / `thumbnail_path` and import missing painting rows from disk |
|
||||
| `npm run dev:sync-image-paths -- --dry-run` | ↑ | Report only, no DB writes |
|
||||
| `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` |
|
||||
| `npm run dev:analyze-painter-palette` | `scripts/analyze-painter-palette.js` | Report CSV ↔ gallery artist name matches |
|
||||
| `npm run dev:export-paintings` | `scripts/export-paintings-csv.js` | Write `Output/paintings.csv` (artist, painting, year) |
|
||||
| `npm run dev:update-painting-annotations` | `scripts/update-painting-annotations.js` | Load curated notes from `painting-annotations-data.js` |
|
||||
| `npm run dev:update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each work’s Wikipedia page |
|
||||
| `npm run dev:update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) |
|
||||
| `npm run dev:find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
|
||||
| `npm run dev:audit-influence-duplicates` | `scripts/audit-influence-duplicates.js` | Report mirrored legacy/sources edges and duplicate influence rows |
|
||||
| `npm run dev:update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
|
||||
| `npm run dev:update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
|
||||
| `npm run dev:update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) |
|
||||
| `npm run dev:discover-influences` | `update-influences.js --discover-only` | Discovery pass only |
|
||||
|
||||
### Scripts in the repository
|
||||
|
||||
These are checked in and maintained:
|
||||
|
||||
- `seed-wikipedia.js`, `seed-catalog-data.js` — initial catalog (`npm run dev:seed`)
|
||||
- `image-fetcher.js` — Wikimedia / museum image resolution
|
||||
- `fetch-artist-images.js` — artist portrait download / disk linking
|
||||
- `sync-image-paths.js` — import paintings and align paths from `data/images/paintings/`
|
||||
- `fetch-artist-bios.js` — artist biographies
|
||||
- `expand-paintings.js` + `famous-paintings-data.js` — catalog expansion
|
||||
- `update-influences.js` + `art-influences-data.js` — influence graph (paintings, artists, movements)
|
||||
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
|
||||
- `fetch-missing-images.js` — batch image backfill
|
||||
- `find-duplicate-paintings.js` — duplicate catalog audit
|
||||
- `migrate-checkup-flags.js` — checkup workflow columns
|
||||
- `audit-influence-duplicates.js` — influence graph duplicate / mirror audit
|
||||
- `painter-palette-lib.js`, `import-painter-palette.js`, `migrate-artist-palette.js`, `analyze-painter-palette.js` — PainterPalette CSV integration
|
||||
- `export-paintings-csv.js` — catalog CSV export to `Output/`
|
||||
- `migrate-checkup-flags.js` — checkup workflow columns (paintings)
|
||||
- `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits)
|
||||
- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail
|
||||
- `regenerate-thumbnails.js`, `audit-painting-images.js`
|
||||
|
||||
These are referenced in `package.json` but may need to be restored from git history if missing locally: `seed-wikipedia.js`, `fetch-artist-images.js`, `sync-image-paths.js`.
|
||||
|
||||
See [data-and-images.md](data-and-images.md) for pipeline details and override maps.
|
||||
|
||||
## Remote repository
|
||||
@@ -146,29 +230,63 @@ Gitea: [Danilka/Art-gallery](https://gitea.mysuperlab.netcraze.pro/Danilka/Art-g
|
||||
git clone https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery.git
|
||||
```
|
||||
|
||||
After clone: copy `.env.example` → `.env`, install dependencies, run `npm run setup` against your Postgres instance, then the post-seed steps above.
|
||||
After clone: copy `.env.example` → `.env`, install dependencies, run [one-time DB split](Documentation/environments.md#step-b--split-the-database-pgadmin), then post-seed steps. Deploy: [environments.md](Documentation/environments.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Empty timeline | DB not seeded | `npm run seed` |
|
||||
| Empty timeline | DB not seeded | `npm run dev:seed` |
|
||||
| 502 / 504 on public URL | Keenetic rule wrong (IP, port, or `https` to device) | Dev → `192.168.10.70:5173`; prod → `192.168.10.122:5173`; protocol **`http`** — see [environments.md](environments.md) |
|
||||
| 503 on public URL | Dev servers not running | `npm run dev:web` (or `dev:server` + `dev:client` for local HMR) |
|
||||
| **`manifest unknown`** on TrueNAS deploy | Image not in Gitea | `npm run prod:docker:publish` on dev PC first |
|
||||
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
|
||||
| “Biographical information not yet available” | Bios not fetched | `npm run fetch-artist-bios` |
|
||||
| Artist hall has only 1–2 paintings | Catalog not expanded | `npm run expand-catalog`; extend `famous-paintings-data.js` |
|
||||
| Black frames / canvas covers in 3D gallery | No local image for painting | `npm run fetch-images -- --limit=50` or `--artist="…"`; then `POST …/preload-images` |
|
||||
| “Biographical information not yet available” | Bios not fetched | `npm run dev:fetch-artist-bios` |
|
||||
| Placeholder portraits on timeline | `portrait_path` not set | `npm run dev:fetch-artist-images` |
|
||||
| Artist hall has only 1–2 paintings | DB not expanded / disk not imported | `npm run dev:sync-image-paths` then `npm run dev:expand-catalog`; extend `famous-paintings-data.js` |
|
||||
| Movement flow shows “Loading art history…” repeatedly | Stale client refetching on every pan/zoom | Pull latest client; timeline loads catalog once — hard-refresh |
|
||||
| Movement flow slow on first load | Large artist payload | Client uses `GET /api/artists?timeline=1` (no full bios); rebuild client |
|
||||
| Movement flow zoom sluggish / dead over portraits | Stale client build | Rebuild client — wheel uses capture listener + interaction-mode rendering; hard-refresh |
|
||||
| Timeline year labels overlap when zoomed out | Stale client | Rebuild client — `chooseTimelineTickInterval()` adapts step to span and bar width |
|
||||
| Black frames / canvas covers in 3D gallery | No local image for painting | `npm run dev:fetch-images -- --limit=50` or `--artist="…"`; then `POST …/preload-images` |
|
||||
| Many `⏱ timeout` lines in fetch batch | Default 10s cap too short for hard works | `--max-wait=120` or raise `FETCH_MAX_WAIT_SEC` |
|
||||
| White/grey flicker on frames | Texture loading or z-fighting with wall | Rebuild client (`cd client && npm run build`); ensure latest `VirtualGallery.tsx` |
|
||||
| Wrong painting in 3D gallery frame | Stale or mismatched thumbnail file | `npm run regenerate-thumbnails`; gallery prefers full `image_path` |
|
||||
| Wrong painting in 3D gallery frame | Stale or mismatched thumbnail file | `npm run dev:regenerate-thumbnails`; gallery prefers full `image_path` |
|
||||
| Wrong painting image (fetch) | Bad museum / search match on first download | Add entry to `DIRECT_IMAGE_OVERRIDES` in `scripts/image-fetcher.js`, re-fetch file |
|
||||
| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` |
|
||||
| Empty *Influenced By* / *Influenced* on painting detail | No edges for that work | `npm run migrate:influence-sources` then `npm run update-influences`; extend `art-influences-data.js` or run `--discover` |
|
||||
| No golden lamps above frames in 3D hall | Stale API process or no influence edges | Restart server after API changes; run `npm run update-influences` |
|
||||
| Empty exit navigation lists | No influence edges for artist | `npm run dev:update-influences`; check `painting_influence_sources` |
|
||||
| Empty *Influenced By* / *Influenced* on painting detail | No edges for that work | `npm run dev:migrate:influence-sources` then `npm run dev:update-influences`; extend `art-influences-data.js` or run `--discover` |
|
||||
| Same work listed twice under *Influenced* | Stale server merging legacy + sources tables | Restart server; API reads `painting_influence_sources` only — run `npm run dev:audit-influence-duplicates` to verify DB |
|
||||
| No golden lamps above frames in 3D hall | Stale API process or no influence edges | Restart server after API changes; run `npm run dev:update-influences` |
|
||||
| Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` |
|
||||
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
|
||||
| Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff |
|
||||
| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes |
|
||||
| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
|
||||
| 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 find-duplicates`; merge or delete spare rows manually |
|
||||
| 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` (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 |
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,527 @@
|
||||
# UI Interaction and Component Standards
|
||||
|
||||
**Subject:** Virtual Art Gallery — screen and interaction guidance
|
||||
**Applies to:** Public visitor UI, 3D halls, curator tools
|
||||
**Companion rule:** `.cursor/rules/ui-interaction-standards.mdc`
|
||||
|
||||
---
|
||||
|
||||
## 0. Introduction
|
||||
|
||||
The Gallery is a single React SPA (`HomePage.tsx` view union — no URL router except `?layout=`). Public visitors browse a museum-dark timeline and 3D halls; curators use the same shell for catalog tools (Checkup, Translations, Influences, Tours, Users, Activity). Work lands in the same header, palette, and back-stack, so a new screen that invents its own chrome, confirmations, or loading pattern fragments the experience.
|
||||
|
||||
This document is the shared contract for **how the UI behaves**. Architecture, APIs, and 3D hall construction live in [basics.md](basics.md), [API.md](API.md), and [data-and-images.md](data-and-images.md). Tree geometry lives in [movement-tree.md](movement-tree.md). Locale strings live in [i18n-russian.md](i18n-russian.md).
|
||||
|
||||
### 0.1 Purpose and audience
|
||||
|
||||
- **Purpose:** Define common UI interaction, navigation, and component standards for the Gallery client.
|
||||
- **Primary audience:** Developers (and agents) adding or changing `client/src` UI; anyone writing curator-tool screens.
|
||||
- **Secondary audience:** QA, product, copy/i18n.
|
||||
|
||||
### 0.2 Scope
|
||||
|
||||
- **In scope:**
|
||||
- Navigation and back-stack behaviour
|
||||
- Timeline / search / 3D / detail / curator-tool interaction patterns
|
||||
- Loading, empty, error, and confirmation behaviour
|
||||
- Permission-based UI (`can()` / RBAC)
|
||||
- Shared component and visual-language rules
|
||||
- **Out of scope:**
|
||||
- REST contracts, retries, and image pipeline internals
|
||||
- Three.js hall architecture, textures, and lighting (see [basics.md](basics.md))
|
||||
- Pixel-perfect branding kit (no separate design-system package; follow existing CSS)
|
||||
|
||||
### 0.3 Requirement levels
|
||||
|
||||
- **MUST:** Mandatory for new work and for fixes that touch the same screen.
|
||||
- **SHOULD:** Recommended; deviate only with a short note in the PR or screen section.
|
||||
- **MAY:** Optional pattern when the screen specification calls for it.
|
||||
|
||||
### 0.4 Surface map
|
||||
|
||||
| Surface | Typical components | Visitors | Curators |
|
||||
|---------|--------------------|----------|----------|
|
||||
| Timeline home | `Timeline`, `VerticalTimeline`, `MovementBands`, `VerticalMovementBands`, `MovementTree`, `CatalogSearchBar` | yes | yes |
|
||||
| 3D hall | `VirtualGallery`, wing navigator, exit overlays | yes | yes |
|
||||
| Painting / bio | `PaintingDetail`, `PaintingLightbox`, `ArtistBio`, `PaintingAnnotations` | yes | + debug panel when `can('images')` |
|
||||
| Overlays | `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal` | some | all |
|
||||
| Curator tools | `CheckupPage`, `TranslationsPage`, `InfluencesPage`, `ToursPage`, `UsersPage`, `AuditPage` | no | permission-gated |
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Goals and Principles
|
||||
|
||||
### 1.1 Problem statement
|
||||
|
||||
- Statement: Visitors and curators share one shell. Inconsistent back labels, ad-hoc modals, English-only captions, and one-off loading/error treatment make the museum feel like several apps glued together.
|
||||
- Scope: All `client/src` screens and overlays.
|
||||
- Rationale: The product is a gallery, not an admin console with a visitor skin. Predictable chrome is part of the exhibit.
|
||||
- Verification: Compare a new screen’s header, back control, loading marker, and locale keys against this document.
|
||||
|
||||
### 1.2 Functional design principles
|
||||
|
||||
- Principle 1: One museum, two roles
|
||||
- Statement: Public browse chrome MUST stay museum-dark (navy / gold / Georgia). Curator tools MAY be denser but MUST reuse the same header back pattern, gold accent, and permission hiding — they MUST NOT look like a separate product.
|
||||
- Rationale: Curators enter from the same timeline; a visual cliff breaks trust.
|
||||
- Verification: Side-by-side with timeline header and Checkup / Tours editor.
|
||||
|
||||
- Principle 2: Predictable drill-down and return
|
||||
- Statement: Navigation MUST follow Timeline → (movement picker) → hall → painting/bio, with an explicit in-app Back that restores the intended session — not the browser history stack.
|
||||
- Rationale: `HomePage` owns view state; the browser Back button is not wired.
|
||||
- Verification: Walk the [basics.md navigation flow](basics.md#user-navigation-flow) and the Back table in §2.4.
|
||||
|
||||
- Principle 3: Clarity of actions and feedback
|
||||
- Statement: Every user-initiated load, save, delete, or failed request MUST show a loading, success, or error state the user can see without opening the console.
|
||||
- Rationale: 3D and image work is slow; silent failure looks like a broken hall.
|
||||
- Verification: Trigger catalog load, hall open, form save, and a failed API call.
|
||||
|
||||
- Principle 4: Minimize cognitive load
|
||||
- Statement: Timeline charts MUST keep zoom/pan/click hints visible. Curator tables MUST put filter/search above the grid. Destructive actions MUST be confirmed.
|
||||
- Rationale: Dense history data and catalog tables are easy to mis-click.
|
||||
- Verification: Hint captions present; filters above tables; delete paths show a confirm.
|
||||
|
||||
- Principle 5: Locale and permission are first-class
|
||||
- Statement: New visitor-facing copy MUST go through `react-i18next` (`locales/{en,ru}`). Actions the user cannot perform MUST be hidden, not disabled-without-explanation.
|
||||
- Rationale: EN/RU is a product requirement; exposing forbidden tools invites errors.
|
||||
- Verification: Toggle EN|RU; log in as a curator without the relevant `can()` and confirm the control is absent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation Principles
|
||||
|
||||
### 2.1 Application chrome (no left module rail)
|
||||
|
||||
The Gallery has **no persistent left navigation menu**. Primary wayfinding is the **timeline header** on home, and an explicit **Back** control on every nested view.
|
||||
|
||||
- Statement: The timeline home MUST keep layout switch, catalog search, locale switcher, and role-appropriate tools in the site header.
|
||||
- Scope: `HomePage` when `view` is `timeline` / `timeline-vertical` / `timeline-tree`.
|
||||
- Rationale: Visitors need search and layout without hunting; curators need tools without leaving the museum frame.
|
||||
- Verification: Header remains usable at 100vh; search dropdown stacks above movement bands (`z-index` on `.site-header`).
|
||||
|
||||
- Statement: Layout switch links MUST stay in the top-left (`.site-layout-switch`). The Tree of Art control SHOULD use `.site-layout-link-feature`.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Three layouts must stay discoverable; Tree is the featured alternative start page.
|
||||
- Verification: Classic / Vertical / Tree links match [basics.md](basics.md#timeline-and-movement-flow).
|
||||
|
||||
- Statement: Nested views (hall, painting, bio, curator pages) MUST NOT reintroduce a second global nav. They MUST show a single primary Back control in the page header.
|
||||
- Scope: All non-home views.
|
||||
- Rationale: Avoid competing menus; the drill-down is the nav.
|
||||
- Verification: No duplicate “home” plus “modules” rails on curator pages.
|
||||
|
||||
### 2.2 Page hierarchy
|
||||
|
||||
- Statement: Screens MUST stay within this hierarchy (max four levels):
|
||||
|
||||
1. Timeline home (classic / vertical / tree)
|
||||
2. Overlay or picker (artist filter, tours popup, login) **or** curator tool page
|
||||
3. 3D hall (artist / movement / tour)
|
||||
4. Painting detail or artist bio (optional lightbox on top of detail)
|
||||
|
||||
- Scope: All visitor and curator flows.
|
||||
- Rationale: Matches the existing drill-down; deeper stacks become unrecoverable without a router.
|
||||
- Verification: New views are added to the `View` union in `HomePage.tsx` with a defined parent and Back handler.
|
||||
|
||||
- Statement: Curator tools MUST open as siblings of the timeline (replace the home canvas), not as a fifth level under a hall.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Tools operate on the catalog, not on a hall session.
|
||||
- Verification: Opening Checkup from a hall is not required; from timeline header, Back returns to timeline home.
|
||||
|
||||
### 2.3 Location awareness (no breadcrumbs)
|
||||
|
||||
- Statement: The app MUST NOT add a breadcrumb trail unless a future router lands. Until then, the Back label MUST name the destination (`← Back to Timeline`, `← Back to Gallery`, `← Back`).
|
||||
- Scope: All nested views.
|
||||
- Rationale: There is no URL path to reflect; a fake breadcrumb would lie.
|
||||
- Verification: Labels match §2.4; they are i18n keys (`backToTimeline`, `backToGallery`, …).
|
||||
|
||||
- Statement: Timeline layout MUST be deep-linkable via `?layout=classic|vertical|tree` (omit param for classic). Other views MUST NOT pretend to be bookmarkable until a router exists.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is the one shareable start-page choice; halls and tools are session state.
|
||||
- Verification: Load `?layout=tree`, switch layouts, confirm `history.replaceState` updates the query.
|
||||
|
||||
### 2.4 Back navigation
|
||||
|
||||
| Control | MUST return to |
|
||||
|---------|----------------|
|
||||
| **← Back to Timeline** (hall header, movement Exit to Timeline, painting opened from search) | Home timeline via `goToTimelineHome()` — unmount hall, clear session, reset year window to full catalog bounds |
|
||||
| **← Back to Gallery** (painting from a hall) | Same hall session (camera / wing preserved) |
|
||||
| **← Back** (artist bio) | `returnTo` view (usually the hall that opened bio) |
|
||||
| Curator page Back | Timeline home |
|
||||
|
||||
- Statement: Back MUST be an in-app control. The browser Back button MUST NOT be relied on (it is not wired to `View` state).
|
||||
- Scope: All nested views.
|
||||
- Rationale: `setView` is the router.
|
||||
- Verification: From painting detail, in-app Back restores the hall; browser Back does not need to.
|
||||
|
||||
- Statement: `goToTimelineHome()` MUST be the single implementation for “leave everything and show the timeline.” New exits MUST call it rather than duplicating reset logic.
|
||||
- Scope: Halls, search-opened paintings, curator Back.
|
||||
- Rationale: Year-range reset and session clear must stay consistent.
|
||||
- Verification: After Exit to Timeline, `viewStart`/`viewEnd` equal catalog bounds.
|
||||
|
||||
### 2.5 State preservation
|
||||
|
||||
- Statement: Timeline pan/zoom (`viewStart` / `viewEnd`) MUST persist while the user stays on a timeline layout. Switching classic ↔ vertical ↔ tree MUST keep the same year window.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is a lens, not a new dataset.
|
||||
- Verification: Zoom, switch to Tree, confirm the year rail range is unchanged.
|
||||
|
||||
- Statement: Returning to timeline via `goToTimelineHome()` MUST reset the year window to full catalog bounds.
|
||||
- Scope: Hall / search / curator exits that call `goToTimelineHome()`.
|
||||
- Rationale: Documented in [basics.md](basics.md#back-navigation); visitors expect a fresh overview, not a leftover zoom.
|
||||
- Verification: Zoom in, enter a hall, Back to Timeline → full span.
|
||||
|
||||
- Statement: Hall camera and wing MUST be preserved across **Back to Gallery** from painting detail. They MUST be discarded on **Back to Timeline**.
|
||||
- Scope: Artist, movement, and tour halls.
|
||||
- Rationale: Inspecting a painting is a detour; leaving the museum is not.
|
||||
- Verification: Move in the hall, open a painting, Back to Gallery → same viewpoint.
|
||||
|
||||
- Statement: Catalog search input MAY clear when the dropdown closes. It MUST NOT change timeline zoom by itself.
|
||||
- Scope: `CatalogSearchBar`.
|
||||
- Rationale: Search is a jump, not a filter on the chart.
|
||||
- Verification: Type a query, Escape; year window unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Common UI Interaction Patterns
|
||||
|
||||
### 3.1 Catalog search (visitor)
|
||||
|
||||
- Statement: Timeline search MUST live in the header, require **2** trimmed characters, debounce **300 ms**, and group results into Artists, Movements, Paintings.
|
||||
- Scope: `CatalogSearchBar.tsx`.
|
||||
- Rationale: Documented product behaviour; keeps `/api/search` load reasonable.
|
||||
- Verification: 1 character shows no fetch; 2+ after debounce shows groups.
|
||||
|
||||
- Statement: Keyboard MUST support `↑`/`↓` highlight, `Enter` to open, `Escape` to close.
|
||||
- Scope: Catalog search dropdown.
|
||||
- Rationale: Timeline is pointer-heavy; search should still be keyboardable.
|
||||
- Verification: Keyboard-only open of an artist, movement, and painting.
|
||||
|
||||
- Statement: Opening a painting from search MUST set `returnTo` timeline so Back is **← Back to Timeline**, not Gallery.
|
||||
- Scope: Search → painting detail.
|
||||
- Rationale: There is no hall session.
|
||||
- Verification: Search a title, open, Back → home timeline.
|
||||
|
||||
### 3.2 Timeline charts (zoom, pan, click)
|
||||
|
||||
- Statement: Scroll MUST zoom, drag MUST pan, click on a stream/limb/label MUST open the movement (artist filter → hall). All three layouts MUST use `zoomTimelineView` / `panTimelineView`.
|
||||
- Scope: `MovementBands`, `VerticalMovementBands`, `MovementTree`, era rails.
|
||||
- Rationale: Shared year window; one mental model.
|
||||
- Verification: Same wheel/drag behaviour on classic, vertical, and tree.
|
||||
|
||||
- Statement: Each chart MUST show a localised hint caption (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`).
|
||||
- Scope: Timeline home.
|
||||
- Rationale: First-time visitors cannot discover zoom/pan otherwise.
|
||||
- Verification: EN and RU captions change with `LocaleSwitcher`.
|
||||
|
||||
- Statement: Timeline layout shifts (lane packing, tree fit scale) SHOULD animate rather than snap.
|
||||
- Scope: Movement charts.
|
||||
- Rationale: Unexplained jumps look like bugs.
|
||||
- Verification: Zoom/pan does not teleport streams.
|
||||
|
||||
### 3.3 Filters and search (curator tables)
|
||||
|
||||
- Statement: Filter/search controls MUST sit in a toolbar **above** the table, not in a column header hack or a page footer.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Matches Checkup (`checkup-toolbar`) and keeps the grid scannable.
|
||||
- Verification: Filters remain visible while the table scrolls.
|
||||
|
||||
- Statement: Simple text filters MAY apply as the user types. Expensive operations (image search, import, refetch) MUST require an explicit button (e.g. Checkup **Search visible**).
|
||||
- Scope: Curator list screens.
|
||||
- Rationale: Checkup search is rate-limited and slow; typing must not fire it.
|
||||
- Verification: Typing in Checkup filter does not start image search.
|
||||
|
||||
- Statement: When a filter hides rows, the toolbar SHOULD show how many rows are visible (e.g. `N shown`).
|
||||
- Scope: Filtered tables.
|
||||
- Rationale: Empty-looking tables need an explanation.
|
||||
- Verification: Filter to zero rows → empty state plus count.
|
||||
|
||||
### 3.4 Date selection
|
||||
|
||||
- Statement: Year fields in the catalog and timeline MUST use numeric years (negative = BCE). They MUST NOT switch to locale-specific calendar widgets for historical BCE dates.
|
||||
- Scope: Timeline bounds, artist lifespan, painting years, curator year filters.
|
||||
- Rationale: The catalog spans −800 to the present; HTML date inputs cannot represent BCE.
|
||||
- Verification: Ancient era still filters correctly.
|
||||
|
||||
- Statement: If a future screen needs a civil date (e.g. audit log day), it SHOULD use ISO `YYYY-MM-DD` and validate start ≤ end for ranges.
|
||||
- Scope: Activity / audit and any new timestamp filters.
|
||||
- Rationale: Consistent with API timestamps; avoids DD/MM ambiguity.
|
||||
- Verification: Invalid range shows a field-level message.
|
||||
|
||||
### 3.5 Tables and lists (curator)
|
||||
|
||||
- Statement: Structured curator datasets MUST use a labeled HTML table (or existing page table classes), one logical record per row.
|
||||
- Scope: Checkup, Users, Influences worklists, Tours list, Translations worklist, Activity.
|
||||
- Rationale: Comparison and row actions need columns, not cards.
|
||||
- Verification: Column headers present; row click/action affects one record.
|
||||
|
||||
- Statement: Visitor-facing catalog MUST NOT be presented as a spreadsheet. Timeline streams, tree limbs, and 3D hangs are the list metaphor.
|
||||
- Scope: Public home and halls.
|
||||
- Rationale: The product is a gallery, not a DAM table.
|
||||
- Verification: No “all paintings” data grid on the public home.
|
||||
|
||||
- Statement: Tables MAY omit pagination while the dataset is curator-sized and client-filtered. If a list grows past comfortable scrolling, it SHOULD paginate or virtualise rather than rendering thousands of DOM rows.
|
||||
- Scope: Curator tools.
|
||||
- Rationale: Checkup is already filter-then-scroll; unbounded paint is a future foot-gun.
|
||||
- Verification: New tools with large lists have a documented paging or virtualisation plan.
|
||||
|
||||
- Statement: Row actions MUST sit in a dedicated column or overflow control, not as random icons in every cell.
|
||||
- Scope: Interactive curator tables.
|
||||
- Rationale: Scanability.
|
||||
- Verification: Action column or consistent button set per row.
|
||||
|
||||
### 3.6 Multi-row and per-record actions
|
||||
|
||||
- Statement: Bulk actions MUST use a leading checkbox column, Select All for **visible** rows only, and a confirmation that includes the affected count for destructive work.
|
||||
- Scope: Any new bulk-enabled table. (Today: ArtistFilterModal multi-select is a picker, not a bulk delete.)
|
||||
- Rationale: Same as the Logistics template; prevent silent mass edits.
|
||||
- Verification: Select All does not imply “all matching in the database” unless explicitly labelled.
|
||||
|
||||
- Statement: Artist filter before a movement hall MUST be a modal checklist with explicit proceed/cancel, not a bulk-edit of the catalog.
|
||||
- Scope: `ArtistFilterModal`.
|
||||
- Rationale: It only chooses who appears in the hall.
|
||||
- Verification: Cancel leaves the user on the timeline; proceed opens the hall.
|
||||
|
||||
- Statement: Primary row/object action SHOULD be the name/title (open painting, open user, open tour). Secondary actions SHOULD stay in the row’s action controls.
|
||||
- Scope: Curator tables and search results.
|
||||
- Rationale: Matches search-result click-to-open.
|
||||
- Verification: Clicking a Checkup title opens the painting when that handler exists.
|
||||
|
||||
- Statement: Unavailable-by-permission actions MUST be hidden. Unavailable-by-record-state SHOULD be disabled with a `title`/tooltip explaining why.
|
||||
- Scope: All tools.
|
||||
- Rationale: RBAC vs workflow are different signals.
|
||||
- Verification: Non-admin does not see Users; a disabled Fix button states why.
|
||||
|
||||
### 3.7 Forms
|
||||
|
||||
- Statement: Short auth and picker flows MUST use a modal. Multi-section catalog editors (Users create/edit, Tours editor, Influences wizard, Translations worklist) MUST be full-page (or the existing page layout), not nested modals.
|
||||
- Scope: All forms.
|
||||
- Rationale: Halls and timeline need to stay the “place”; heavy edit needs space.
|
||||
- Verification: Login is modal; Users is a page.
|
||||
|
||||
- Statement: Forms SHOULD be a single column. Related fields MAY group under a heading when there are more than five inputs.
|
||||
- Scope: Curator forms.
|
||||
- Rationale: Scanning beats dense multi-column on museum-width pages.
|
||||
- Verification: Users create form remains vertically grouped.
|
||||
|
||||
- Statement: Required fields MUST use the native `required` attribute and/or a visible marker; validation errors MUST appear next to the field or as a form-level error the submit control does not obscure.
|
||||
- Scope: Login, Users, Tours, Influences, Translations.
|
||||
- Rationale: Silent submit-disable is not enough.
|
||||
- Verification: Submit empty login → field or form error, not a blank modal.
|
||||
|
||||
- Statement: After successful save, the system MUST show an inline success message (or equivalent) and keep the user on the tool unless the spec says to return to timeline.
|
||||
- Scope: Curator mutations.
|
||||
- Rationale: Users page already uses `message` / `error` banners.
|
||||
- Verification: Save permissions → success text; failed save → error text.
|
||||
|
||||
- Statement: Unsaved-change guards SHOULD be added when a form is long enough that accidental Back would lose work (Tours editor, Translations). Login and tiny pickers MAY skip this.
|
||||
- Scope: Heavy editors.
|
||||
- Rationale: `window.confirm` on delete already exists; abandon-edit is the remaining hole.
|
||||
- Verification: Dirty Tours editor + Back prompts or discards explicitly.
|
||||
|
||||
### 3.8 Edit interaction pattern selection
|
||||
|
||||
| Pattern | Use when |
|
||||
|---------|----------|
|
||||
| **Modal** | Login, artist filter, tours list popup, debug image picker, lightbox, hall exit/wing overlays |
|
||||
| **Inline** | Checkup flags, debug **Checked** / **Fix it** on painting detail — small, reversible |
|
||||
| **Full page** | Curator tools, painting detail, artist bio, 3D hall |
|
||||
| **Drawer** | MUST NOT be introduced unless a spec adds a shared drawer component |
|
||||
|
||||
- Statement: Nested modals MUST NOT be used (no modal opened from another modal). The debug “More” picker MAY stack on painting detail because detail is a full page, not a modal.
|
||||
- Scope: All overlays.
|
||||
- Rationale: Focus traps and Back labels break.
|
||||
- Verification: Login does not open another dialog.
|
||||
|
||||
### 3.9 Modals and overlays
|
||||
|
||||
- Statement: Modals MUST use `role="dialog"` and `aria-modal="true"`, a visible close/cancel, and **Escape** to dismiss unless a submit is in flight.
|
||||
- Scope: `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal`, `PaintingLightbox`, hall exit overlays.
|
||||
- Rationale: Accessibility and parity with search.
|
||||
- Verification: Esc closes lightbox and debug picker; backdrop click matches existing login behaviour.
|
||||
|
||||
- Statement: Backdrop click MAY close pickers and login. It MUST NOT close a modal that is applying a destructive or long-running action.
|
||||
- Scope: Overlays.
|
||||
- Rationale: Accidental dismiss during Fix/upload is costly.
|
||||
- Verification: Click outside login closes; do not dismiss mid-upload.
|
||||
|
||||
### 3.10 Loading, empty, and error states
|
||||
|
||||
- Statement: Catalog, portrait, and hall loads MUST use `GalleryLoadingMarker` (overlay or banner), not an ad-hoc spinner per screen unless the marker cannot cover the region.
|
||||
- Scope: Home, halls, painting/bio image work.
|
||||
- Rationale: One recognisable “the museum is fetching” treatment.
|
||||
- Verification: First visit shows “Loading art history…”; hall open uses the same marker family.
|
||||
|
||||
- Statement: Loading SHOULD be scoped to the affected region. Full-viewport overlay MUST be used only when the user cannot usefully interact (first catalog load, hall WebGL init).
|
||||
- Scope: All loads.
|
||||
- Rationale: Portrait banner vs full-page overlay already follows this.
|
||||
- Verification: Timeline remains visible while “Loading portraits…” banners.
|
||||
|
||||
- Statement: Empty datasets MUST explain themselves (e.g. vertical flow: no movements in range; search: no matches; Checkup: no rows for filter).
|
||||
- Scope: Charts, search, tables.
|
||||
- Rationale: Blank gold-on-navy reads as a crash.
|
||||
- Verification: Zoom to a year with no movements; search a nonsense string.
|
||||
|
||||
- Statement: Failed loads MUST set a visible error string (`error-banner`, form error, or page error) — never `console.error` alone.
|
||||
- Scope: All data-fetching views.
|
||||
- Rationale: Visitors have no console.
|
||||
- Verification: Stop the API and confirm home shows a load failure message.
|
||||
|
||||
### 3.11 3D hall interaction
|
||||
|
||||
- Statement: Halls MUST keep **← Back to Timeline**, pointer-lock / click-to-move as already implemented, and **E** (or documented key) for exit/wing navigation. New hall UI MUST not steal those keys without updating this section.
|
||||
- Scope: `VirtualGallery`.
|
||||
- Rationale: Muscle memory across artist, movement, and tour halls.
|
||||
- Verification: Same Back label and exit overlay pattern in all three hall kinds.
|
||||
|
||||
- Statement: Clicking a framed painting MUST open painting detail with `returnTo` the current hall. Missing images MUST show the draped-canvas placeholder, not a broken `<img>`.
|
||||
- Scope: Halls.
|
||||
- Rationale: Documented in [basics.md](basics.md).
|
||||
- Verification: Work without a file still shows a frame cover.
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavioral Standards (Screen UX)
|
||||
|
||||
### 4.1 Permission-based rendering (RBAC)
|
||||
|
||||
- Statement: Header tools and debug controls MUST render only when `can('<permission>')` (and login for curator). Anonymous visitors MUST see browse + Tours popup + locale, not Checkup/Users/etc.
|
||||
- Scope: `HomePage` header, painting/bio debug panel.
|
||||
- Rationale: Security UX: hide, don’t tease.
|
||||
- Verification: Logged-out home; curator without `users` cannot open Users.
|
||||
|
||||
- Statement: Route-like views that require a role MUST show the existing “Curator access required” panel with login and back-to-gallery actions — they MUST NOT render an empty privileged page.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Deep view state can still be set; the gate must hold.
|
||||
- Verification: Set view to Users while logged out → curator required copy.
|
||||
|
||||
- Statement: The same permission MUST hide the same action on every surface (header, painting debug, API). Do not leave a visible button that 403s.
|
||||
- Scope: All mutations.
|
||||
- Rationale: Predictable roles.
|
||||
- Verification: `can('images')` off → no debug panel and no Fix buttons.
|
||||
|
||||
### 4.2 Notifications and user feedback
|
||||
|
||||
- Statement: Curator mutations MUST show success or error text in the page’s existing banner/message area. The app has no global toast system; new screens MUST NOT invent a third notification widget without replacing this standard.
|
||||
- Scope: Curator pages.
|
||||
- Rationale: Users/Influences already use inline `message` / `error`.
|
||||
- Verification: One visual treatment per page, consistent placement under the header.
|
||||
|
||||
- Statement: Visitor-facing destructive actions (painting **Remove entry** in debug) MUST confirm and then show failure inline if the API rejects.
|
||||
- Scope: Debug painting tools.
|
||||
- Rationale: Catalog deletes are irreversible.
|
||||
- Verification: Cancel confirm → no delete.
|
||||
|
||||
- Statement: Copy MUST be concise and, for errors, actionable (“Is the server running?”, “Sign in as a curator…”).
|
||||
- Scope: All user-visible strings.
|
||||
- Rationale: Support load.
|
||||
- Verification: Home catalog failure string remains understandable.
|
||||
|
||||
### 4.3 Confirmation and cancellation
|
||||
|
||||
- Statement: Destructive curator actions (delete influence, delete tour, remove painting, deactivate user if offered) MUST confirm before the request. `window.confirm` with an i18n string is the current standard (`confirmDelete`); a shared modal MAY replace it later but MUST stay one pattern.
|
||||
- Scope: Influences, Tours, painting remove, Users.
|
||||
- Rationale: Accidental clicks on dense tables.
|
||||
- Verification: Delete tour → confirm; Cancel → no API call.
|
||||
|
||||
- Statement: Cancel on a confirm MUST leave filters, selection, and unsaved fields unchanged.
|
||||
- Scope: All confirms.
|
||||
- Rationale: Context preservation.
|
||||
- Verification: Filtered Checkup, cancel a destructive action → filter still applied.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Consistency Rules
|
||||
|
||||
### 5.1 Shared interaction pattern usage
|
||||
|
||||
- Statement: New UI MUST reuse existing components before creating parallels: `GalleryLoadingMarker`, `CatalogSearchBar`, `LocaleSwitcher`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Scope: `client/src`.
|
||||
- Rationale: Duplicate spinners and dialogs already caused drift.
|
||||
- Verification: PR does not add a second login modal or loading overlay.
|
||||
|
||||
- Statement: Movement colours MUST go through `utils/movementColor.ts` (`vividMovementColor`, `shadeMovementColor`). Hex parsing MUST tolerate `#rgb` / `#rrggbb` and keep the first six digits of longer values.
|
||||
- Scope: Timeline charts and any movement swatch.
|
||||
- Rationale: Classic, vertical, and tree already share this util.
|
||||
- Verification: No local `parseHexColor` copies in components.
|
||||
|
||||
- Statement: Timeline zoom/pan MUST use `utils/timelineView.ts`. Tree horizontal layout MUST use `utils/movementTree.ts` (view-independent structure).
|
||||
- Scope: Timeline surfaces.
|
||||
- Rationale: Layouts share one year window.
|
||||
- Verification: No one-off wheel handlers that bypass the util.
|
||||
|
||||
### 5.2 Component and style selection
|
||||
|
||||
- Statement: There is **no Ant Design / MUI**. UI MUST be React + colocated CSS (`ComponentName.tsx` + `ComponentName.css`). Global museum tokens SHOULD reuse existing values:
|
||||
|
||||
| Token | Typical value | Use |
|
||||
|-------|----------------|-----|
|
||||
| Gold | `#c9a96e` / `rgba(201, 169, 110, …)` | Accents, borders, links |
|
||||
| Ink / navy | `#0f0f1a`, `#1a1a2e`, `#16213e` | Page background |
|
||||
| Type | Georgia, serif | Titles, captions, layout links |
|
||||
| Viewport | `100vh`, `overflow: hidden` on home | No document scroll for the museum shell |
|
||||
|
||||
- Scope: All client UI.
|
||||
- Rationale: The look *is* the design system.
|
||||
- Verification: New CSS does not introduce a bright Bootstrap theme.
|
||||
|
||||
- Statement: Prefer semantic `<button type="button">` for actions and native `<form>` for submits. Do not use `<div onClick>` for primary actions.
|
||||
- Scope: All interactive chrome.
|
||||
- Rationale: Keyboard and a11y.
|
||||
- Verification: Header tools are buttons.
|
||||
|
||||
- Statement: Colocate styles; do not add a CSS-in-JS runtime. Shared layout classes live in `HomePage.css` / page CSS, not inline theme objects.
|
||||
- Scope: Client.
|
||||
- Rationale: Matches the repo.
|
||||
- Verification: New component ships a `.css` file or uses an existing one.
|
||||
|
||||
### 5.3 i18n
|
||||
|
||||
- Statement: New user-visible chrome MUST add keys to `client/src/locales/en/*.json` and `ru/*.json` (namespace that matches the screen: `home`, `common`, `debug`, `users`, …). Hardcoded English is allowed only for curator-only screens that are not yet migrated, and those SHOULD be migrated when the screen is touched.
|
||||
- Scope: All new copy.
|
||||
- Rationale: [i18n-russian.md](i18n-russian.md).
|
||||
- Verification: Locale toggle changes the new string.
|
||||
|
||||
- Statement: Catalog entities (names, titles, bios) MUST use API locale resolution, not a second client-side dictionary.
|
||||
- Scope: Timeline labels, search, halls, detail.
|
||||
- Rationale: `entity_translations` is canonical for RU catalog text.
|
||||
- Verification: RU locale shows published translations.
|
||||
|
||||
### 5.4 Exception process
|
||||
|
||||
- Statement: Deviations (new overlay type, toast system, left nav, router, design library) MUST be documented in this file or in the feature’s `Documentation/*.md` with rationale before they spread to a second screen.
|
||||
- Scope: Client-wide patterns.
|
||||
- Rationale: One exception is an experiment; two without a write-up is fragmentation.
|
||||
- Verification: PR description links the exception note.
|
||||
|
||||
- Statement: Known current exceptions (do not cargo-cult; fix when touching the file):
|
||||
- Browser history is not a router.
|
||||
- Only `?layout=` is deep-linked.
|
||||
- Some curator pages (e.g. Checkup) still have English chrome.
|
||||
- Some modals may still omit Escape (login); new modals MUST include it.
|
||||
- Destructive confirms use `window.confirm` rather than a shared dialog component.
|
||||
|
||||
### 5.5 Versioning of behaviour
|
||||
|
||||
- Statement: Behaviour changes to shared patterns (Back, search debounce, layout query, loading marker) MUST update this document in the same change.
|
||||
- Scope: Shared components and `HomePage` navigation.
|
||||
- Rationale: Agents and humans use this as the spec.
|
||||
- Verification: Doc diff accompanies the code diff.
|
||||
|
||||
---
|
||||
|
||||
## 6. Screen-Level Checklist
|
||||
|
||||
Use this before submitting a new view, overlay, or curator tool. Unchecked items need a note.
|
||||
|
||||
- [ ] Surface type is identified (timeline / hall / detail / overlay / curator page).
|
||||
- [ ] Parent view and Back label/destination are defined (`returnTo` or `goToTimelineHome`).
|
||||
- [ ] Permissions: controls hidden unless `can(…)` / signed in as required.
|
||||
- [ ] Loading uses `GalleryLoadingMarker` or a justified scoped indicator.
|
||||
- [ ] Empty and error states are visible copy, not a blank canvas.
|
||||
- [ ] Destructive actions confirm; cancel leaves state unchanged.
|
||||
- [ ] Visitor chrome strings are in `locales/{en,ru}`; catalog text uses API locale.
|
||||
- [ ] No new CSS framework; gold/navy/Georgia; colocated CSS.
|
||||
- [ ] Search/filter (if any) sits above the list; expensive work is explicit-button.
|
||||
- [ ] Modals: `role="dialog"`, close control, Escape.
|
||||
- [ ] Timeline work uses `timelineView` / `movementColor` / `movementTree` as applicable.
|
||||
- [ ] Hall work preserves camera on Back to Gallery and resets on Back to Timeline.
|
||||
- [ ] This document updated if a shared pattern changed.
|
||||
@@ -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 1250–1400* (1966)
|
||||
9. Millard Meiss — *Painting in Florence and Siena after the Black Death* (1951)
|
||||
10. Bruce Cole — *Giotto and Florentine Painting 1280–1375* (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., 1943–1960)
|
||||
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., 1969–1975)
|
||||
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 1500–1600* (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 1600–1750* (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 1703–1770* (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 1848–1851* (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.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,257 @@
|
||||
"id","name","birth_year","death_year","movement_id","portrait_path","bio_short","bio_full","wikipedia_title","century","checkup_checked","checkup_fixed","palette_metadata","portrait_thumb_path"
|
||||
47,"Honoré Daumier",1808,1879,12,"portraits/Honor__Daumier.jpg","Honoré-Victorin Daumier (French: [ɔnɔʁe domje]; 26 February 1808 – 10 or 11 February 1879) was a French painter, sculptor, and printmaker, whose many works offer commentary on the social and political life in France, from the Revolution of 1830 to the fall of the Second French Empire in 1870. He earned a living producing caricatures and cartoons in newspapers and periodicals such as La Caricature and Le Charivari, for which he became well known in his lifetime and is still remembered today.","Honoré-Victorin Daumier (French: [ɔnɔʁe domje]; 26 February 1808 – 10 or 11 February 1879) was a French painter, sculptor, and printmaker, whose many works offer commentary on the social and political life in France, from the Revolution of 1830 to the fall of the Second French Empire in 1870. He earned a living producing caricatures and cartoons in newspapers and periodicals such as La Caricature and Le Charivari, for which he became well known in his lifetime and is still remembered today. He was a republican democrat (working class liberal), who satirized and lampooned the monarchy, aristocracy, clergy, politicians, the judiciary, lawyers, police, detectives, the wealthy, the military, the bourgeoisie, as well as his countrymen and human nature in general.
|
||||
Daumier was a serious painter, loosely associated with realism, sometimes blurring the boundaries between caricature and fine art. Although he occasionally exhibited at the Parisian Salon, his paintings were largely overlooked and ignored by the French public and critics of the day. Yet Daumier's fellow painters, as well as the poet and art critic Charles Baudelaire, noticed and greatly admired his work. Later generations would recognize Daumier as one of the great French artists of the 19th century, profoundly influencing a younger generation of impressionist and postimpressionist painters. Daumier was a tireless and prolific artist and produced more than 100 sculptures, 500 paintings, 1,000 drawings, 1,000 wood engravings, and 4,000 lithographs.
|
||||
Daumier came from a poor family and was working by the age of 12, first at a huissier de justice, then at a bookstore frequented by artists where he began to draw. He received some mentorship from Alexandre Lenoir, attended the Académie Suisse, learned lithography, and was producing advertisements, illustrations, and caricatures by the time he was twenty. After the July Revolution of 1830 he begin working for satirical political papers that were highly critical of the new monarch Louis Philippe I and his court. He was jailed for several months in 1832 after the publication of Gargantua, a particularly offensive depiction of the King, Louis Philippe. After his release Daumier resumed publishing political lithographs until the September Laws were passed in 1835, limiting the freedom of the press. Afterwards, his cartoons softened, the bourgeoisie and daily Parisian life were more frequent subjects, and when political subjects did appear they were oblique and veiled. Daumier experienced financial hardships and debt throughout much of his life.
|
||||
Daumier married Alexandrine Dassy in 1846 and moved to the Île Saint-Louis where they lived until 1863. He increasingly associated with writers, poets, painters, and sculptors there, including Baudelaire, Corot, Courbet, Delacroix among others, and began to paint in earnest. He spent his summers from 1853 onward in Barbizon and Valmondois, where artists of the Barbizon school and realist movement worked. As his desire to paint intensified, his enthusiasm for cartooning declined, as did his popularity with the public. Le Charivari stopped publishing his comics in 1860. A period of financial hardship followed, and from 1863–1865 he moved to a series of lodgings around Montmartre and lost contact with many friends. Le Charivari gave him a new contract in 1864 and he resumed making caricatures for an appreciative audience in Paris. Daumier moved to Valmondois in 1865. He experienced failing eyesight and poverty there, although he continued to produce lithographs and paint, often on the theme of Don Quixote. The French Third Republic granted him a pension in 1877, and the following year a major exhibition of his paintings was held in Paris, which received significant recognition in the final months of his life. Daumier died in February 1879. Various sources give conflicting dates regarding the day of his death: some state 10 February 1879, others 11 February.","Honoré Daumier",1800,False,False,"{""gender"": null, ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Orientalism, Realism, Romanticism, Unknown"", ""movement"": ""Realism"", ""last_year"": 1879, ""birth_year"": 1808, ""death_year"": null, ""first_year"": 1822, ""pupils_raw"": null, ""birth_place"": ""Marseille"", ""citizenship"": null, ""death_place"": null, ""nationality"": ""French"", ""occupations"": null, ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Impressionism:10},{Orientalism:1},{Realism:228},{Romanticism:14},{Unknown:1}"", ""art500k_movements"": ""{Realism:290}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Jean-Francois Millet,"", ""wikiart_pictures_count"": 254}","portraits/thumbs/Honor__Daumier_thumb.jpg"
|
||||
18,"Claude Lorrain",1600,1682,8,"portraits/Claude_Lorrain.jpg","ʁɛ̃]; born Claude Gellée [ʒəle], called le Lorrain in French; traditionally just Claude in English; c. 1600 – 23 November 1682) was a painter, draughtsman and etcher of the Baroque era originally from the Duchy of Lorraine.","Claude Lorrain (French: [klod lɔ.ʁɛ̃]; born Claude Gellée [ʒəle], called le Lorrain in French; traditionally just Claude in English; c. 1600 – 23 November 1682) was a painter, draughtsman and etcher of the Baroque era originally from the Duchy of Lorraine. He spent most of his life in Italy, and is one of the earliest significant artists, aside from his contemporaries in Dutch Golden Age painting, to concentrate on landscape painting. His landscapes often transitioned into the more prestigious genre of history paintings by addition of a few small figures, typically representing a scene from the Bible or classical mythology.
|
||||
By the end of the 1630s, he was established as the leading landscapist in Italy, and he enjoyed large fees for his work. His landscapes gradually became larger, but with fewer figures, more carefully painted, and produced at a lower rate. He was not generally an innovator in landscape painting, except in introducing the sun and streaming sunlight into many paintings, which had been rare before. He is now thought of as a French painter, but was born in the independent Duchy of Lorraine, and almost all his painting was done in Italy; before the late 19th century he was regarded as a painter of the ""Roman School"". His patrons were also mostly Italian, but after his death he became very popular with English collectors, and the UK retains a high proportion of his works.
|
||||
|
||||
He was a prolific creator of drawings in pen and very often monochrome watercolour ""wash"", usually brown but sometimes grey. Chalk is sometimes used for under-drawing, and white highlighting in various media may be employed, much less often other colours such as pink. These fall into three fairly distinct groups. Firstly, there are numerous sketches, mostly of landscapes, often created on-site; these have been greatly admired, and influenced other artists. Then there are studies for paintings, of various degrees of finish, many clearly done before or during the process of painting, while others were likely made after the painting was completed. This was certainly the case for the last group, the 195 drawings recording finished paintings collected in his Liber Veritatis (now in the British Museum). He produced over 40 etchings, often simplified versions of paintings, mainly before 1642. These served various purposes for him, but are now regarded as much less important than his drawings. He painted frescoes in his early career, which played an important part in making his reputation, but are now nearly all lost.","Claude Lorrain",1600,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Classicism, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1682, ""birth_year"": 1600, ""death_year"": 1682, ""first_year"": 1630, ""pupils_raw"": null, ""birth_place"": ""Chamagne"", ""citizenship"": ""Duchy of Lorraine"", ""death_place"": ""Rome"", ""nationality"": ""French"", ""occupations"": ""drawer, printmaker, painter"", ""teachers_raw"": ""Agostino Tassi,Claude Deruet,"", ""painting_school"": null, ""styles_extended"": ""{Baroque:19},{Classicism:80},{Unknown:4}"", ""art500k_movements"": ""{Baroque:8},{Baroque,Rococo:88}"", ""influenced_by_raw"": ""Annibale Carracci,"", ""influenced_on_raw"": ""John Constable,Aelbert Cuyp,Jan Dirksz Both,Camille Corot,Joseph Anton Koch,"", ""wikiart_pictures_count"": 103}","portraits/thumbs/Claude_Lorrain_thumb.jpg"
|
||||
49,"J. M. W. Turner",1775,1851,11,"portraits/J__M__W__Turner.jpg","Joseph Mallord William Turner (London, 23 April 1775 – London, 19 December 1851), known in his time as William Turner, was an English Romantic painter, printmaker and watercolourist. He is known for his expressive colouring, imaginative landscapes and turbulent, often violent marine paintings.","Joseph Mallord William Turner (London, 23 April 1775 – London, 19 December 1851), known in his time as William Turner, was an English Romantic painter, printmaker and watercolourist. He is known for his expressive colouring, imaginative landscapes and turbulent, often violent marine paintings. His artistic style developed over his lifetime, moving away from Romanticism—bypassing the following rising style of Realism—and, instead, with his later works being a significant precursor of and presaging the later Impressionist and Abstract Art movements that arose in the decades after his death. He left behind more than 550 oil paintings, 2,000 watercolours, and 30,000 works on paper. He was championed by the leading English art critic John Ruskin from 1840, and is today regarded as having elevated landscape painting to an eminence rivalling history painting. In 1969 art historian Kenneth Clark wrote of Turner: ""He was a genius of the first order—far the greatest painter that England has ever produced ..."".
|
||||
Turner was born in Maiden Lane, Covent Garden, London, to a modest lower-middle-class family and retained his lower-class accent, while assiduously avoiding the trappings of success and fame. A child prodigy, Turner studied at the Royal Academy of Arts from 1789, enrolling when he was 14, and exhibited his first work there at 15. During this period, he also served as an architectural draftsman. He earned a steady income from commissions and sales, which he often only begrudgingly accepted owing to his troubled and contrary nature. He opened his own gallery in 1804 and became professor of perspective at the academy in 1807, where he lectured until 1828. He travelled around Europe from 1802, typically returning with voluminous sketchbooks.
|
||||
Intensely private, eccentric, and reclusive, Turner was a controversial figure throughout his career. He did not marry, but fathered two daughters, Evelina (1801–1874) and Georgiana (1811–1843), by the widow Sarah Danby. He became more pessimistic and morose as he got older, especially after the death of his father in 1829; when his outlook deteriorated, his gallery fell into disrepair and neglect, and his art intensified. In 1841, Turner rowed a boat into the Thames so he could not be counted as present at any property in that year's census. He lived in squalor and poor health from 1845, and died in London in 1851 aged 76. Turner is buried in St Paul's Cathedral, London.","J. M. W. Turner",1700,False,False,"{""gender"": null, ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1844, ""birth_year"": 1775, ""death_year"": null, ""first_year"": 1798, ""pupils_raw"": null, ""birth_place"": ""London"", ""citizenship"": null, ""death_place"": null, ""nationality"": null, ""occupations"": null, ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Impressionism:1},{Romanticism:243},{Unknown:1}"", ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 245}","portraits/thumbs/J__M__W__Turner_thumb.jpg"
|
||||
63,"Lyubov Popova",1889,1924,22,"portraits/Lyubov_Popova.jpg","Lyubov Sergeyevna Popova (Russian: Любо́вь Серге́евна Попо́ва; 24 April 1889 – 25 May 1924) was a Russian-Soviet avant-garde artist, painter and designer.","Lyubov Sergeyevna Popova (Russian: Любо́вь Серге́евна Попо́ва; 24 April 1889 – 25 May 1924) was a Russian-Soviet avant-garde artist, painter and designer.","Lyubov Popova",1800,False,False,"{""gender"": ""female"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Constructivism, Cubism, Cubo-Futurism, Expressionism, Suprematism, Unknown"", ""movement"": ""Cubism"", ""last_year"": 1922, ""birth_year"": 1925, ""death_year"": 1996, ""first_year"": 1909, ""pupils_raw"": null, ""birth_place"": ""Minusinsk"", ""citizenship"": null, ""death_place"": ""Kharkiv"", ""nationality"": ""Russian"", ""occupations"": ""opera singer"", ""teachers_raw"": null, ""painting_school"": ""Supremus"", ""styles_extended"": ""{Abstract Art:11},{Constructivism:3},{Cubism:15},{Cubo-Futurism:25},{Expressionism:1},{Suprematism:7},{Unknown:4}"", ""art500k_movements"": ""{Cubism,Suprematism,Constructivism:36}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 66}","portraits/thumbs/Lyubov_Popova_thumb.jpg"
|
||||
86,"Roy Lichtenstein",1923,1997,26,"portraits/Roy_Lichtenstein.jpg","Roy Fox Lichtenstein ( LIK-tən-STYN; October 27, 1923 – September 29, 1997) was an American artist. A leading figure of the Pop Art movement, he is best known for his large-scale paintings inspired by comic books, advertisements, and mass-produced imagery.","Roy Fox Lichtenstein ( LIK-tən-STYN; October 27, 1923 – September 29, 1997) was an American artist. A leading figure of the Pop Art movement, he is best known for his large-scale paintings inspired by comic books, advertisements, and mass-produced imagery. Lichtenstein's art is represented in major museum collections worldwide, and he remains one of the most influential and recognizable artists of the 20th century.
|
||||
Emerging in the early 1960s, Lichtenstein gained international recognition for works that employed bold outlines, flat colors, and his signature use of Ben-Day dots—a mechanical printing technique he meticulously replicated by hand. Through this approach, Lichtenstein challenged traditional distinctions between ""high"" art and popular culture, transforming seemingly banal source material into monumental, self-aware compositions. His work often explored themes of romance, war, consumerism, and art itself, frequently incorporating irony and detachment to comment on modern visual culture.
|
||||
Beyond his comic-inspired paintings, Lichtenstein's wide-ranging career included sculpture, murals, prints, and reinterpretations of canonical works by artists such as Picasso, Monet, and Matisse. His best-known works include Look Mickey (1961), Whaam! (1963), and Drowning Girl (1963), which helped define his visual language and establish Pop Art as a dominant movement of the era. Lichtenstein's most expensive work, Masterpiece (1962), sold privately in 2017 for a reported $165 million.
|
||||
Lichtenstein received numerous accolades during his career, including election to the American Academy of Arts and Letters in 1979 and the National Medal of Arts in 1995. Lichtenstein also received several Honorary Doctorates in Fine Art from institutions, including California Institute of the Arts, Ohio State University, and George Washington University.","Roy Lichtenstein",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Cubism, Expressionism, Pop Art, Surrealism, Unknown"", ""movement"": ""Pop Art"", ""last_year"": 1994, ""birth_year"": 1923, ""death_year"": 1997, ""first_year"": 1908, ""pupils_raw"": null, ""birth_place"": ""Manhattan"", ""citizenship"": ""United States of America"", ""death_place"": ""Manhattan"", ""nationality"": ""American"", ""occupations"": ""painter, sculptor, university teacher, graphic artist, jewelry designer, scenographer, designer, printmaker, architectural draftsperson, lithographer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Abstract Art:18},{Cubism:9},{Expressionism:3},{Pop Art:117},{Surrealism:1},{Unknown:4}"", ""art500k_movements"": ""{Pop Art:119}"", ""influenced_by_raw"": ""Fernand Leger,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 152}","portraits/thumbs/Roy_Lichtenstein_thumb.jpg"
|
||||
45,"Henri Privat-Livemont",1861,1936,16,"portraits/Henri_Privat-Livemont.jpg","Privat Antoine Théodore Livemont (1861–1936) was a Belgian Symbolist painter and Art Nouveau decorator who sometimes worked under the name T. Privat-Livemont.","Privat Antoine Théodore Livemont (1861–1936) was a Belgian Symbolist painter and Art Nouveau decorator who sometimes worked under the name T. Privat-Livemont.","Henri Privat-Livemont",1800,False,False,NULL,"portraits/thumbs/Henri_Privat-Livemont_thumb.jpg"
|
||||
15,"Caravaggio",1571,1610,8,"portraits/Caravaggio.jpg","Michelangelo Merisi da Caravaggio (also Michele Angelo Merigi or Amerighi da Caravaggio; 29 September 1571 – 18 July 1610), known mononymously as Caravaggio, was an Italian painter active in Rome for a significant portion of his artistic life. He began his apprenticeship in Milan in 1584, and during the final four years of his life, he moved between Naples, Malta, and Sicily.","Michelangelo Merisi da Caravaggio (also Michele Angelo Merigi or Amerighi da Caravaggio; 29 September 1571 – 18 July 1610), known mononymously as Caravaggio, was an Italian painter active in Rome for a significant portion of his artistic life. He began his apprenticeship in Milan in 1584, and during the final four years of his life, he moved between Naples, Malta, and Sicily. His paintings have been characterized by art critics as combining a realistic observation of the human state, both physical and emotional, with a dramatic use of lighting, which had a formative influence on Baroque painting.
|
||||
Caravaggio employed close physical observation with a dramatic use of chiaroscuro that came to be known as tenebrism. He made the technique a dominant stylistic element, transfixing subjects in bright shafts of light and darkening shadows. Caravaggio vividly expressed crucial moments and scenes, often featuring violent struggles, torture, and death. He worked rapidly with live models, preferring to forgo drawings and work directly on the canvas. His effect on the new Baroque style that emerged from Mannerism was profound. His influence can be seen directly or indirectly in the work of Peter Paul Rubens, Jusepe de Ribera, Gian Lorenzo Bernini, Velázquez and Rembrandt, as well as many lesser-known artists. Artists most deeply influenced by him were called the ""Caravaggisti"" (or ""Caravagesques""), as well as tenebrists or tenebrosi (""shadowists"").
|
||||
Caravaggio trained as a painter in Milan before moving to Rome when he was in his twenties. He developed a considerable name as an artist and as a violent, touchy and provocative man. He killed Ranuccio Tomassoni in a brawl, which led to a death sentence for murder and forced him to flee to Naples. There he again established himself as one of the most prominent Italian painters of his generation. He travelled to Malta and on to Sicily in 1607 and pursued a papal pardon for his sentence. In 1609, he returned to Naples, where he was involved in a violent clash; his face was disfigured, and rumours of his death circulated. Questions about his mental state arose from his erratic and bizarre behavior. He died in 1610 under uncertain circumstances while on his way from Naples to Rome. Reports stated that he died of a fever, possibly due to wounds. Suggestions have been made that he was murdered or that he died of lead poisoning.
|
||||
Caravaggio's innovations inspired Baroque painting, which often incorporated the drama of his chiaroscuro with a lesser degree of psychological disturbance. As style evolved and fashions changed, Caravaggio fell out of favor. In the 20th century, interest in his work was revived, and his importance to the development of Western art was reassessed. The 20th-century art historian André Berne-Joffroy stated: ""What begins in the work of Caravaggio is, quite simply, modern painting.""","Caravaggio",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Tenebrism, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1799, ""birth_year"": 1571, ""death_year"": 1610, ""first_year"": 1592, ""pupils_raw"": null, ""birth_place"": ""Milan"", ""citizenship"": ""Duchy of Milan"", ""death_place"": ""Porto Ercole"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Baroque:21},{Tenebrism:20},{Unknown:64}"", ""art500k_movements"": ""{Baroque:80},{Renaissance:1}"", ""influenced_by_raw"": ""Giorgione,Titian,"", ""influenced_on_raw"": ""Gustave Courbet,Artemisia Gentileschi,Diego Velazquez,Hendrick Terbrugghen,Peter Paul Rubens,Johannes Vermeer,Georges de la Tour,Jusepe de Ribera,Rembrandt,Eugene Delacroix,Edouard Manet,Jacob Jordaens,Francisco de Zurbaran,Jacques-Louis David,Giorgio Morandi,Caravaggisti,Odd Nerdrum,"", ""wikiart_pictures_count"": 105}","portraits/thumbs/Caravaggio_thumb.jpg"
|
||||
66,"Masaccio",1401,1428,4,"portraits/Masaccio.jpg","Masaccio (UK: , US: ; Italian: [maˈzattʃo]; December 21, 1401 – summer 1428), born Tommaso di Ser Giovanni di Simone, was a Florentine artist who is regarded as the first great Italian painter of the Quattrocento period of the Italian Renaissance. According to Vasari, Masaccio was the best painter of his generation because of his skill at imitating nature, recreating lifelike figures and movements as well as a convincing sense of three-dimensionality.","Masaccio (UK: , US: ; Italian: [maˈzattʃo]; December 21, 1401 – summer 1428), born Tommaso di Ser Giovanni di Simone, was a Florentine artist who is regarded as the first great Italian painter of the Quattrocento period of the Italian Renaissance. According to Vasari, Masaccio was the best painter of his generation because of his skill at imitating nature, recreating lifelike figures and movements as well as a convincing sense of three-dimensionality. He employed nudes and foreshortenings in his figures. This had seldom been done before him.
|
||||
The name Masaccio is a humorous version of Maso (short for Tommaso), meaning ""clumsy"" or ""messy"" Tom. The name may have been created to distinguish him from his principal collaborator, also called Maso, who came to be known as Masolino (""little/delicate Tom"").
|
||||
Despite his brief career, he had a profound influence on other artists and is considered to have started the Early Italian Renaissance in painting with his works in the mid- and late-1420s. He was one of the first to use linear perspective in his painting, employing techniques such as vanishing point in art for the first time. He moved away from the International Gothic style and elaborate ornamentation of artists like Gentile da Fabriano to a more naturalistic mode that employed perspective and chiaroscuro for greater realism.
|
||||
Masaccio died at the age of twenty-six and little is known about the exact circumstances of his death. Upon hearing of Masaccio's death, Filippo Brunelleschi said: ""We have suffered a great loss.""","Masaccio",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, Unknown"", ""movement"": ""Early Renaissance"", ""last_year"": 1428, ""birth_year"": 1401, ""death_year"": 1428, ""first_year"": 1420, ""pupils_raw"": null, ""birth_place"": ""San Giovanni Valdarno"", ""citizenship"": null, ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": null, ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Early Renaissance:21},{Unknown:6}"", ""art500k_movements"": ""{Early Renaissance:28}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Leonardo da Vinci,Michelangelo,Sandro Botticelli,"", ""wikiart_pictures_count"": 27}","portraits/thumbs/Masaccio_thumb.jpg"
|
||||
87,"Salvador Dalí",1904,1989,24,"portraits/Salvador_Dal_.jpg","Salvador Domingo Felipe Jacinto Dalí i Domènech, Marquess of Dalí de Púbol (11 May 1904 – 23 January 1989), known as Salvador Dalí, was a Spanish surrealist artist renowned for his technical skill, precise draftsmanship, and the striking and bizarre images in his work.
|
||||
Born in Figueres in Catalonia, Dalí received his formal education in fine arts in Madrid.","Salvador Domingo Felipe Jacinto Dalí i Domènech, Marquess of Dalí de Púbol (11 May 1904 – 23 January 1989), known as Salvador Dalí, was a Spanish surrealist artist renowned for his technical skill, precise draftsmanship, and the striking and bizarre images in his work.
|
||||
Born in Figueres in Catalonia, Dalí received his formal education in fine arts in Madrid. Influenced by Impressionism and the Renaissance masters from a young age, he became increasingly attracted to Cubism and avant-garde movements. He moved closer to Surrealism in the late 1920s and joined the Surrealist group in 1929, soon becoming one of its leading exponents. His best-known work, The Persistence of Memory, was completed in August 1931. Dalí lived in France throughout the Spanish Civil War (1936 to 1939) before leaving for the United States in 1940 where he achieved commercial success. He returned to Spain in 1948 where he announced his return to the Catholic faith and developed his ""nuclear mysticism"" style, based on his interest in classicism, mysticism, and recent scientific developments.
|
||||
Dalí's artistic repertoire included painting, sculpture, film, graphic arts, animation, fashion, and photography, at times in collaboration with other artists. He also wrote fiction, poetry, autobiography, essays, and criticism. Major themes in his work include dreams, the subconscious, sexuality, religion, science and his closest personal relationships. To the dismay of those who held his work in high regard, and to the irritation of his critics, his eccentric and ostentatious public behavior often drew more attention than his artwork. His public support for the Francoist regime, his commercial activities and the quality and authenticity of some of his late works have also been controversial. His life and work were an important influence on other Surrealists, pop art, popular culture, and contemporary artists such as Jeff Koons and Damien Hirst.
|
||||
There are two major museums devoted to Salvador Dalí's work: the Dalí Theatre-Museum in Figueres, Spain, and the Salvador Dalí Museum in St. Petersburg, Florida, U.S.","Salvador Dalí",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Abstract Expressionism, Cloisonnism, Cubism, Divisionism, Expressionism, Impressionism, Kitsch, Metaphysical art, Naïve Art (Primitivism), Neoclassicism, Op Art, Pointillism, Post-Impressionism, Realism, Surrealism, Symbolism, Unknown"", ""movement"": ""Surrealism"", ""last_year"": 1983, ""birth_year"": 1904, ""death_year"": 1989, ""first_year"": 1910, ""pupils_raw"": null, ""birth_place"": ""Figueres"", ""citizenship"": ""Spain"", ""death_place"": ""Figueres"", ""nationality"": ""Spanish"", ""occupations"": ""screenwriter, photographer, actor, writer, illustrator, painter, sculptor, graphic artist, jewelry designer, film director, scenographer, film actor, holographer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Abstract Art:3},{Abstract Expressionism:11},{Cloisonnism:4},{Cubism:22},{Divisionism:1},{Expressionism:101},{Impressionism:31},{Kitsch:1},{Metaphysical art:1},{Naïve Art (Primitivism):4},{Neoclassicism:7},{Op Art:3},{Pointillism:6},{Post-Impressionism:58},{Realism:26},{Surrealism:775},{Symbolism:116},{Unknown:8}"", ""art500k_movements"": ""{Surrealism:1161},{Modern art:1}"", ""influenced_by_raw"": ""Giorgio de Chirico,Arnold Böcklin,Pablo Picasso,Joan Miro,Yves Tanguy,Max Ernst,Jean-Francois Millet,Dada,High Renaissance,"", ""influenced_on_raw"": ""Max Ernst,Jackson Pollock,Mark Rothko,Surrealism,Abstract Expressionism,Pop Art,Performance Art,Conceptual Art,"", ""wikiart_pictures_count"": 1178}","portraits/thumbs/Salvador_Dal__thumb.jpg"
|
||||
91,"Theophanes the Greek",1340,1410,2,"portraits/Theophanes_the_Greek.gif","Theophanes the Greek (Russian: Феофан Грек, romanized: Feofan Grek; Greek: Θεοφάνης; c. 1330/1340 – c.","Theophanes the Greek (Russian: Феофан Грек, romanized: Feofan Grek; Greek: Θεοφάνης; c. 1330/1340 – c. 1410) was a Byzantine Greek artist, active mainly in Russia.
|
||||
He greatly influenced the style of painting in Novgorod and Moscow in the 15th century. He is also known as being the teacher and mentor of Andrei Rublev, the greatest Russian icon painter of his time.","Theophanes the Greek",1300,False,False,NULL,"portraits/thumbs/Theophanes_the_Greek_thumb.jpg"
|
||||
2,"Alexander Rodchenko",1891,1956,22,"portraits/Alexander_Rodchenko.jpg","S. 23 November] 1891 – 3 December 1956) was a Russian and Soviet artist, sculptor, photographer, and graphic designer.","Aleksander Mikhailovich Rodchenko (Russian: Александр Михайлович Родченко; 5 December [O.S. 23 November] 1891 – 3 December 1956) was a Russian and Soviet artist, sculptor, photographer, and graphic designer. He was one of the founders of constructivism and Russian design; he was married to the artist Varvara Stepanova.
|
||||
Rodchenko was one of the most versatile constructivist and productivist artists to emerge after the Russian Revolution. He worked as a painter and graphic designer before turning to photomontage and photography. His photography was socially engaged, formally innovative, and opposed to a painterly aesthetic. Concerned with the need for analytical-documentary photo series, he often shot his subjects from odd angles—usually high above or down below—to shock the viewer and to postpone recognition. He wrote: ""One has to take several different shots of a subject, from different points of view and in different situations, as if one examined it in the round rather than looked through the same key-hole again and again.""
|
||||
He is also known for developing the early corporate identity of the airline Dobrolyot, later Aeroflot, and designed its ""Winged Hammer and Sickle"" logo.","Alexander Rodchenko",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Constructivism, Cubo-Futurism, Unknown"", ""movement"": ""Avant-garde"", ""last_year"": 1915, ""birth_year"": 1891, ""death_year"": 1956, ""first_year"": 1915, ""pupils_raw"": null, ""birth_place"": ""Saint Petersburg"", ""citizenship"": ""Soviet Union"", ""death_place"": ""Moscow"", ""nationality"": ""Russian"", ""occupations"": ""photographer, architect, graphic designer, painter, typographer, sculptor, graphic artist, visual artist, designer"", ""teachers_raw"": null, ""painting_school"": ""Oktyabr (October),LEF (Left Front of the Arts)"", ""styles_extended"": ""{Abstract Art:5},{Constructivism:53},{Cubo-Futurism:2},{Unknown:26}"", ""art500k_movements"": ""{Constructivism:24}"", ""influenced_by_raw"": ""И это стоит столетья,Artists2/Alexander Rodchenko/Cover Of Book About That 1923.jpg,Маяковский улыбается, смеется, издевается,Рекламный плакат для Резинотреста,Лёт. Авиа-стихи.,Рекламный плакат для Моссельпрома,Artists2/Alexander Rodchenko/Arvatov About Mayakovsky 1923.jpg,Artists2/Alexander Rodchenko/Dobroliot Fly Well 1923.jpg,Artists2/Alexander Rodchenko/Watches 1923.jpg,Artists2/Alexander Rodchenko/About That 1923.jpg,24 x 20 cm,Artists2/Alexander Rodchenko/Nonsense 1921.jpg,Artists2/Alexander Rodchenko/Abstraction Rupture 1920.jpg,160 x 129 cm,Беспредметная композиция № 65,53 x 21 cm,Композиция,Artists2/Alexander Rodchenko/Objectless Composition 65 Still Life 1918.jpg,Белый круг,90 x 62 cm,40.2 x 35.5 cm,Конструкция,78 x 50.8 cm,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 86}","portraits/thumbs/Alexander_Rodchenko_thumb.jpg"
|
||||
6,"Andrei Rublev",1360,1430,2,"portraits/Andrei_Rublev.jpg","Andrei Rublev (Russian: Андрей Рублёв, romanized: Andrey Rublyov, IPA: [ɐnˈdrʲej rʊˈblʲɵf] ; c. 1360 – c.","Andrei Rublev (Russian: Андрей Рублёв, romanized: Andrey Rublyov, IPA: [ɐnˈdrʲej rʊˈblʲɵf] ; c. 1360 – c. 1430) was a Russian artist considered to be one of the greatest medieval Russian painters of Orthodox Christian icons and frescoes. He is revered as a saint in the Eastern Orthodox Church, and his feast day is 29 January.","Andrei Rublev",1300,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Moscow school of icon painting, Unknown"", ""movement"": ""Byzantine Art"", ""last_year"": 1427, ""birth_year"": 1360, ""death_year"": 1430, ""first_year"": 1411, ""pupils_raw"": ""Paintings for Annunciation Cathedral in the Moscow Kremlin,Khitrovo Gospels,"", ""birth_place"": ""Grand Principality of Moscow"", ""citizenship"": ""Grand Principality of Moscow"", ""death_place"": ""Andronikov Monastery"", ""nationality"": ""Russians"", ""occupations"": ""iconographer, painter, illuminator"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Moscow school of icon painting:55},{Unknown:2}"", ""art500k_movements"": ""{Byzantine Art:58}"", ""influenced_by_raw"": ""Christianity,Annunciation,St.-Matthew,St. Luke,St. Mark,Christianity,Jesus-Christ,Christianity,saints-and-apostles,Jesus-Christ,Nativity,Virgin-Mary,Christianity,saints-and-apostles,St. Mark,Khitrovo-Gospels,Christianity,saints-and-apostles,Khitrovo-Gospels,Christianity,saints-and-apostles,St. Luke,Khitrovo-Gospels,Christianity,saints-and-apostles,St.-Matthew,angels-and-archangels,Khitrovo-Gospels,Christianity,saints-and-apostles,St.-Matthew,Khitrovo-Gospels,Christianity,arts-and-crafts,saints-and-apostles,St. Luke,Khitrovo-Gospels,"", ""influenced_on_raw"": ""Trinity Cathedral (Trinity Lavra of St.Sergius), Sergiyev Posad, Russia,Cathedral of the Annunciation (Moscow Kremlin), Moscow, Russia,Russian State Library, Moscow, Russia,"", ""wikiart_pictures_count"": 57}","portraits/thumbs/Andrei_Rublev_thumb.jpg"
|
||||
10,"Arnold Böcklin",1827,1901,15,"portraits/Arnold_B_cklin.jpg","Arnold Böcklin ([ˈaʁnɔlt ˈbœklin]; 16 October 1827 – 16 January 1901) was a Swiss Symbolist painter. His five versions of the Isle of the Dead inspired works by several late Romantic composers.","Arnold Böcklin ([ˈaʁnɔlt ˈbœklin]; 16 October 1827 – 16 January 1901) was a Swiss Symbolist painter. His five versions of the Isle of the Dead inspired works by several late Romantic composers.","Arnold Böcklin",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Romanticism, Symbolism, Unknown"", ""movement"": ""Symbolism"", ""last_year"": 1898, ""birth_year"": 1827, ""death_year"": 1901, ""first_year"": 1846, ""pupils_raw"": null, ""birth_place"": ""Basel"", ""citizenship"": ""Switzerland"", ""death_place"": ""Fiesole"", ""nationality"": ""Swiss"", ""occupations"": ""painter, sculptor, university teacher, drawer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Romanticism:20},{Symbolism:102},{Unknown:2}"", ""art500k_movements"": ""{Symbolism:124},{Modern art:1}"", ""influenced_by_raw"": ""Caspar David Friedrich,Romanticism,"", ""influenced_on_raw"": ""Giorgio de Chirico,Max Ernst,Salvador Dali,"", ""wikiart_pictures_count"": 124}","portraits/thumbs/Arnold_B_cklin_thumb.jpg"
|
||||
9,"Apelles",-400,-320,1,"portraits/Apelles.jpg","Apelles of Kos (; Ancient Greek: Ἀπελλῆς; fl. 4th century BC) was a renowned painter of ancient Greece.","Apelles of Kos (; Ancient Greek: Ἀπελλῆς; fl. 4th century BC) was a renowned painter of ancient Greece. Pliny the Elder, to whom much of modern scholars' knowledge of this artist is owed (Naturalis Historia 35.36.79–97 and passim), rated him superior to preceding and subsequent artists. He dated Apelles to the 112th Olympiad (332–329 BC), possibly because he had produced a portrait of Alexander the Great.","Apelles",-400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Classical Period (480 - 323 BC),Hellenistic Period (323-31 BC):8}"", ""last_year"": null, ""birth_year"": 369, ""death_year"": 305, ""first_year"": null, ""pupils_raw"": null, ""birth_place"": ""Colophon"", ""citizenship"": null, ""death_place"": ""Kos"", ""nationality"": ""Greek"", ""occupations"": ""['writer', 'painter']"", ""teachers_raw"": null, ""painting_school"": ""Heptanese (Ionian) School,Sicyonian school"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": null}","portraits/thumbs/Apelles_thumb.jpg"
|
||||
8,"Angelica Kauffman",1741,1807,10,"portraits/Angelica_Kauffman.jpg","Maria Anna Angelika Kauffmann ( KOWF-mən; 30 October 1741 – 5 November 1807), usually known in English as Angelica Kauffman, was a Swiss painter who had a successful career in London and Rome. Remembered primarily as a history painter, Kauffman was a skilled portraitist, landscape and decoration painter.","Maria Anna Angelika Kauffmann ( KOWF-mən; 30 October 1741 – 5 November 1807), usually known in English as Angelica Kauffman, was a Swiss painter who had a successful career in London and Rome. Remembered primarily as a history painter, Kauffman was a skilled portraitist, landscape and decoration painter. She was, along with Mary Moser, one of two female painters among the founding members of the Royal Academy of Art in London in 1768.","Angelica Kauffman",1700,False,False,"{""gender"": ""female"", ""source"": ""PainterPalette.csv"", ""styles"": ""Neoclassicism, Rococo, Unknown"", ""movement"": ""Rococo"", ""last_year"": 1798, ""birth_year"": 1741, ""death_year"": 1807, ""first_year"": 1763, ""pupils_raw"": null, ""birth_place"": ""Chur"", ""citizenship"": ""Switzerland"", ""death_place"": ""Rome"", ""nationality"": ""Austrian"", ""occupations"": ""artist, painter, printmaker"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Neoclassicism:50},{Rococo:30},{Unknown:1}"", ""art500k_movements"": ""{Neoclassicism:57}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 81}","portraits/thumbs/Angelica_Kauffman_thumb.jpg"
|
||||
95,"Vladimir Tatlin",1885,1953,22,"portraits/Vladimir_Tatlin.jpg","S. 16 December] 1885 – 31 May 1953) was a Russian and Soviet painter, architect, and stage-designer.","Vladimir Yevgrafovich Tatlin (28 December [O.S. 16 December] 1885 – 31 May 1953) was a Russian and Soviet painter, architect, and stage-designer. Tatlin achieved fame as the architect who designed The Monument to the Third International, more commonly known as Tatlin's Tower, which he began in 1919. With Kazimir Malevich he was one of the two most important figures in the Soviet avant-garde art movement of the 1920s, and he later became an important artist in the constructivist movement.","Vladimir Tatlin",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Constructivism, Cubism, Expressionism, Post-Impressionism, Realism, Unknown"", ""movement"": ""Abstract Art"", ""last_year"": 1950, ""birth_year"": 1885, ""death_year"": 1953, ""first_year"": 1910, ""pupils_raw"": null, ""birth_place"": ""Kharkiv"", ""citizenship"": ""Soviet Union"", ""death_place"": ""Moscow"", ""nationality"": ""Russian,Ukrainian"", ""occupations"": ""architect, illustrator, painter, sculptor, university teacher, scenographer, designer, printmaker, architectural draftsperson, collagist, assemblage artist"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Abstract Art:3},{Constructivism:7},{Cubism:6},{Expressionism:2},{Post-Impressionism:5},{Realism:1},{Unknown:1}"", ""art500k_movements"": ""{Constructivism:25}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 25}","portraits/thumbs/Vladimir_Tatlin_thumb.jpg"
|
||||
17,"Cimabue",1240,1302,2,"portraits/Cimabue.jpg","Giovanni Cimabue ( CHEE-mə-BOO-ay, Italian: [tʃimaˈbuːe]; c. 1240 – 1302), also known as Cenni di Pepo or Cenni di Pepi, was an Italian painter and designer of mosaics from Florence.","Giovanni Cimabue ( CHEE-mə-BOO-ay, Italian: [tʃimaˈbuːe]; c. 1240 – 1302), also known as Cenni di Pepo or Cenni di Pepi, was an Italian painter and designer of mosaics from Florence.
|
||||
Although heavily influenced by Byzantine models, Cimabue is generally regarded as one of the first great Italian painters to break from the Italo-Byzantine style. Compared with the norms of medieval art, his works have more lifelike figural proportions and a more sophisticated use of shading to suggest volume. According to Italian painter and historian Giorgio Vasari, Cimabue was the teacher of Giotto, the first great artist of the Italian Proto-Renaissance. However, many scholars today tend to discount Vasari's claim by citing earlier sources that suggest otherwise.","Cimabue",1200,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Byzantine"", ""movement"": ""Proto Renaissance"", ""last_year"": 1295, ""birth_year"": 1240, ""death_year"": 1302, ""first_year"": 1270, ""pupils_raw"": ""Giotto,"", ""birth_place"": ""Florence"", ""citizenship"": ""Republic of Florence"", ""death_place"": ""Pisa"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": null, ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Byzantine:18}"", ""art500k_movements"": ""{Gothic art:1},{Proto Renaissance:18}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 18}","portraits/thumbs/Cimabue_thumb.jpg"
|
||||
22,"Duccio",1255,1319,3,"portraits/Duccio.jpg","Duccio di Buoninsegna (UK: DOO-chee-oh, Italian: [ˈduttʃo di ˌbwɔninˈseɲɲa]; c. 1255/1260 – c.","Duccio di Buoninsegna (UK: DOO-chee-oh, Italian: [ˈduttʃo di ˌbwɔninˈseɲɲa]; c. 1255/1260 – c. 1318/1319), commonly known as just Duccio, was an Italian painter active in Siena, Tuscany, in the late 13th and early 14th century. He was hired throughout his life to complete many important works in government and religious buildings around Italy. Duccio is considered one of the greatest Italian painters of the Middle Ages, and is credited with creating the painting styles of Trecento Gothic style and the Sienese school.","Duccio",1200,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Proto Renaissance"", ""movement"": ""Proto Renaissance"", ""last_year"": 1318, ""birth_year"": 1255, ""death_year"": 1319, ""first_year"": 1280, ""pupils_raw"": null, ""birth_place"": ""Siena"", ""citizenship"": ""Italy"", ""death_place"": ""Siena"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": null, ""painting_school"": ""Sienese School"", ""styles_extended"": ""{Proto Renaissance:135}"", ""art500k_movements"": ""{Proto Renaissance:135}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Ambrogio Lorenzetti,Pietro Lorenzetti,"", ""wikiart_pictures_count"": 135}","portraits/thumbs/Duccio_thumb.jpg"
|
||||
21,"Domenico Ghirlandaio",1448,1494,4,"portraits/Domenico_Ghirlandaio.jpg","Domenico di Tommaso Curradi di Doffo Bigordi (2 June 1448 – 11 January 1494), professionally known as Domenico Ghirlandaio (also spelt as Ghirlandajo), was an Italian Renaissance painter born in Florence. Ghirlandaio was part of the so-called ""third generation"" of the Florentine Renaissance, along with Verrocchio, the Pollaiolo brothers and Sandro Botticelli.","Domenico di Tommaso Curradi di Doffo Bigordi (2 June 1448 – 11 January 1494), professionally known as Domenico Ghirlandaio (also spelt as Ghirlandajo), was an Italian Renaissance painter born in Florence. Ghirlandaio was part of the so-called ""third generation"" of the Florentine Renaissance, along with Verrocchio, the Pollaiolo brothers and Sandro Botticelli.
|
||||
Ghirlandaio led a large and efficient workshop that included his brothers Davide Ghirlandaio and Benedetto Ghirlandaio, his brother-in-law Bastiano Mainardi from San Gimignano, and later his son Ridolfo Ghirlandaio. Many apprentices passed through Ghirlandaio's workshop, including Michelangelo.
|
||||
His particular talent lay in his ability to posit depictions of contemporary life and portraits of contemporary people within the context of religious narratives, bringing him great popularity and many large commissions.","Domenico Ghirlandaio",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, Unknown"", ""movement"": ""Early Renaissance"", ""last_year"": 1494, ""birth_year"": 1448, ""death_year"": 1494, ""first_year"": 1470, ""pupils_raw"": ""Michelangelo,"", ""birth_place"": ""Florence"", ""citizenship"": ""Republic of Florence"", ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": ""Andrea del Verrochio,"", ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Early Renaissance:104},{Unknown:4}"", ""art500k_movements"": ""{Early Renaissance:108}"", ""influenced_by_raw"": ""Hugo van der Goes,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 108}","portraits/thumbs/Domenico_Ghirlandaio_thumb.jpg"
|
||||
14,"Camille Pissarro",1830,1903,13,"portraits/Camille_Pissarro.jpg","Jacob Abraham Camille Pissarro ( piss-AR-oh; French: [kamij pisaʁo]; 10 July 1830 – 13 November 1903) was a Danish-French Impressionist and Neo-Impressionist painter born on the island of Saint Thomas (now in the US Virgin Islands, but then in the Danish West Indies). His importance resides in his contributions to both Impressionism and Post-Impressionism.","Jacob Abraham Camille Pissarro ( piss-AR-oh; French: [kamij pisaʁo]; 10 July 1830 – 13 November 1903) was a Danish-French Impressionist and Neo-Impressionist painter born on the island of Saint Thomas (now in the US Virgin Islands, but then in the Danish West Indies). His importance resides in his contributions to both Impressionism and Post-Impressionism. Pissarro studied from great forerunners, including Gustave Courbet and Jean-Baptiste-Camille Corot. He later studied and worked alongside Georges Seurat and Paul Signac when he took on the Neo-Impressionist style at the age of 54.
|
||||
In 1873 he helped establish a collective society of fifteen aspiring artists, becoming the ""pivotal"" figure in holding the group together and encouraging the other members. Art historian John Rewald called Pissarro the ""dean of the Impressionist painters"", not only because he was the oldest of the group, but also ""by virtue of his wisdom and his balanced, kind, and warmhearted personality"". Paul Cézanne said ""he was a father for me. A man to consult and a little like the good Lord"", and he was also one of Paul Gauguin's masters. Pierre-Auguste Renoir referred to his work as ""revolutionary"", through his artistic portrayals of the ""common man"", as Pissarro insisted on painting individuals in natural settings without ""artifice or grandeur"".
|
||||
Pissarro is the only artist to have shown his work at all eight Paris Impressionist exhibitions, from 1874 to 1886. He ""acted as a father figure not only to the Impressionists"" but to all four of the major Post-Impressionists, Cézanne, Seurat, Gauguin, and Van Gogh.","Camille Pissarro",1800,True,True,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Neo-Impressionism, Realism, Unknown"", ""movement"": ""Impressionism"", ""last_year"": 1903, ""birth_year"": 1830, ""death_year"": 1903, ""first_year"": 1830, ""pupils_raw"": ""Paul Gauguin,"", ""birth_place"": ""Charlotte Amalie"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French,Jewish"", ""occupations"": ""pastellist, lithographer, architectural draftsperson, printmaker, graphic artist, painter"", ""teachers_raw"": ""Camille Corot,"", ""painting_school"": null, ""styles_extended"": ""{Impressionism:788},{Neo-Impressionism:40},{Realism:56},{Unknown:5}"", ""art500k_movements"": ""{Impressionism,Post-Impressionism:890}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Paul Gauguin,Pierre-Auguste Renoir,"", ""wikiart_pictures_count"": 889}","portraits/thumbs/Camille_Pissarro_thumb.jpg"
|
||||
24,"Édouard Manet",1832,1883,12,"portraits/_douard_Manet.jpg","Édouard Manet (UK: , US: ; French: [edwaʁ manɛ]; 23 January 1832 – 30 April 1883) was a French modernist painter. He was one of the first 19th-century artists to paint modern life, as well as a pivotal figure in the transition from Realism to Impressionism.","Édouard Manet (UK: , US: ; French: [edwaʁ manɛ]; 23 January 1832 – 30 April 1883) was a French modernist painter. He was one of the first 19th-century artists to paint modern life, as well as a pivotal figure in the transition from Realism to Impressionism.
|
||||
Born into an upper-class household with strong political connections, Manet rejected the naval career originally envisioned for him; he became engrossed in the world of painting. His early masterworks, The Luncheon on the Grass (French: Le Déjeuner sur l'herbe) and Olympia, premiering in 1863 and '65, respectively, caused great controversy with both critics and the Academy of Fine Arts, but soon were praised by progressive artists as the breakthrough acts to the new style, Impressionism. These works, along with others, are considered watershed paintings that mark the start of modern art. The last 20 years of Manet's life saw him form bonds with other great artists of the time; he developed his own simple and direct style that would be heralded as innovative and serve as a major influence for future painters.","Édouard Manet",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Realism, Unknown"", ""movement"": ""Realism"", ""last_year"": 1882, ""birth_year"": 1832, ""death_year"": 1883, ""first_year"": 1858, ""pupils_raw"": null, ""birth_place"": ""former 10th arrondissement of Paris"", ""citizenship"": ""France"", ""death_place"": ""8th arrondissement of Paris"", ""nationality"": null, ""occupations"": ""lithographer, drawer, architectural draftsperson, painter, illustrator"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Impressionism:106},{Realism:102},{Unknown:27}"", ""art500k_movements"": ""{Modern art:1},{Impressionism:1},{Realism,Impressionism:232}"", ""influenced_by_raw"": ""Gustave Courbet,El Greco,Caravaggio,Eugene Delacroix,"", ""influenced_on_raw"": ""Edward Hopper,Ilya Repin,Paul Gauguin,Edgar Degas,Pierre-Auguste Renoir,Louise Abbéma,"", ""wikiart_pictures_count"": 235}","portraits/thumbs/_douard_Manet_thumb.jpg"
|
||||
28,"Eugène Delacroix",1798,1863,11,"portraits/Eug_ne_Delacroix.jpg","Ferdinand Victor Eugène Delacroix ( DEL-ə-krwah, -KRWAH; French: [øʒɛn dəlakʁwa]; 26 April 1798 – 13 August 1863) was a French Romantic artist who was regarded as the leader of the French Romantic school.
|
||||
In contrast to the Neoclassical perfectionism of his chief rival Ingres, Delacroix took for his inspiration the art of Rubens and painters of the Venetian Renaissance, with an attendant emphasis on colour and movement rather than clarity of outline and carefully modelled form.","Ferdinand Victor Eugène Delacroix ( DEL-ə-krwah, -KRWAH; French: [øʒɛn dəlakʁwa]; 26 April 1798 – 13 August 1863) was a French Romantic artist who was regarded as the leader of the French Romantic school.
|
||||
In contrast to the Neoclassical perfectionism of his chief rival Ingres, Delacroix took for his inspiration the art of Rubens and painters of the Venetian Renaissance, with an attendant emphasis on colour and movement rather than clarity of outline and carefully modelled form. Dramatic and romantic content characterized the central themes of his maturity, and led him not to the classical models of Greek and Roman art, but to travel in North Africa in search of the exotic. Friend and spiritual heir to Théodore Géricault, Delacroix was also inspired by Lord Byron, with whom he shared a strong identification with the ""forces of the sublime"" of nature in often violent action.
|
||||
However, Delacroix was given to neither sentimentality nor bombast, and his Romanticism was that of an individualist. In the words of Baudelaire, ""Delacroix was passionately in love with passion, but coldly determined to express passion as clearly as possible."" Together with Ingres, Delacroix is considered one of the last old Masters of painting and is one of the few who was ever photographed.
|
||||
As a painter and muralist, Delacroix's use of expressive brushstrokes and his study of the optical effects of colour profoundly shaped the work of the Impressionists, while his passion for the exotic inspired the artists of the Symbolist movement. A fine lithographer, Delacroix illustrated various works of William Shakespeare, the Scottish author Walter Scott, and the German author Johann Wolfgang von Goethe.","Eugène Delacroix",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Orientalism, Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1863, ""birth_year"": 1798, ""death_year"": 1863, ""first_year"": 1820, ""pupils_raw"": null, ""birth_place"": ""Saint-Maurice"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""pastellist, diarist, lithographer, drawer, architectural draftsperson, muralist, painter, photographer"", ""teachers_raw"": ""Pierre-Narcisse Guerin,"", ""painting_school"": null, ""styles_extended"": ""{Orientalism:67},{Romanticism:158},{Unknown:11}"", ""art500k_movements"": ""{Romanticism:226}"", ""influenced_by_raw"": ""El Greco,Caravaggio,John Constable,Théodore Géricault,Peter Paul Rubens,Venetian School,Richard Parkes Bonington,Paolo Veronese,Tintoretto,Antoine-Jean Gros,"", ""influenced_on_raw"": ""Paul Gauguin,Edgar Degas,Pierre-Auguste Renoir,Edouard Manet,Impressionism,Camille Corot,"", ""wikiart_pictures_count"": 236}","portraits/thumbs/Eug_ne_Delacroix_thumb.jpg"
|
||||
1,"Albrecht Dürer",1471,1528,5,"portraits/Albrecht_D_rer.jpg","Albrecht Dürer ( DURE-ər, German: [ˈalbʁɛçt ˈdyːʁɐ]; 21 May 1471 – 6 April 1528), sometimes spelled in English as Durer or Duerer, was a German painter, printmaker, and theorist of the German Renaissance. Born in Nuremberg, Dürer established his reputation and influence across Europe in his twenties due to his high-quality woodcut prints.","Albrecht Dürer ( DURE-ər, German: [ˈalbʁɛçt ˈdyːʁɐ]; 21 May 1471 – 6 April 1528), sometimes spelled in English as Durer or Duerer, was a German painter, printmaker, and theorist of the German Renaissance. Born in Nuremberg, Dürer established his reputation and influence across Europe in his twenties due to his high-quality woodcut prints. He was in contact with the major Italian artists of his time, including Raphael, Giovanni Bellini and Leonardo da Vinci, and from 1512 was patronized by Emperor Maximilian I.
|
||||
Dürer's vast body of work includes engravings, his preferred technique in his later prints, altarpieces, portraits and self-portraits, watercolours and books. The woodcuts series are stylistically more Gothic than the rest of his work, but revolutionised the potential of that medium, while his extraordinary handling of the burin expanded especially the tonal range of his engravings.
|
||||
Dürer's introduction of classical motifs and of the nude into Northern art, through his knowledge of Italian artists and German humanists, has secured his reputation as one of the most important figures of the Northern Renaissance. This is reinforced by his theoretical treatises, which involve principles of mathematics for linear perspective and body proportions.","Albrecht Dürer",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Northern Renaissance, Renaissance, Unknown"", ""movement"": ""Northern Renaissance"", ""last_year"": 1588, ""birth_year"": 1471, ""death_year"": 1528, ""first_year"": 1481, ""pupils_raw"": null, ""birth_place"": ""Nuremberg"", ""citizenship"": ""Duchy of Bavaria"", ""death_place"": ""Nuremberg"", ""nationality"": ""German"", ""occupations"": ""exlibrist, art theorist, drawer, copperplate engraver, printmaker, painter, illuminator, illustrator, mathematician"", ""teachers_raw"": ""Martin Schongauer,"", ""painting_school"": ""German School"", ""styles_extended"": ""{Northern Renaissance:815},{Renaissance:1},{Unknown:28}"", ""art500k_movements"": ""{Northern Renaissance:856},{German Renaissance:11},{Renaissance:15}"", ""influenced_by_raw"": ""Andrea Mantegna,Rogier van der Weyden,"", ""influenced_on_raw"": ""Raphael,Titian,Parmigianino,Jacopo Bassano,Bartolomeo Veneto,Francisco Goya,Wilhelm von Kaulbach,Hans Hoffmann,"", ""wikiart_pictures_count"": 844}","portraits/thumbs/Albrecht_D_rer_thumb.jpg"
|
||||
26,"El Greco",1541,1614,7,"portraits/El_Greco.jpg","Doménikos Theotokópoulos (Greek: Δομήνικος Θεοτοκόπουλος, pronounced [ðoˈminikos θeotoˈkopulos]; 1 October 1541 – 7 April 1614), most widely known as El Greco (Spanish pronunciation: [el ˈɣɾeko]; ""The Greek""), was a Greek painter, sculptor and architect of the Spanish Renaissance, regarded as one of the greatest artists of all time. El Greco was a nickname, and the artist normally signed his paintings with his full birth name in Greek letters often adding the word Κρής (Krḗs), which means ""Cretan"" in Ancient Greek.","Doménikos Theotokópoulos (Greek: Δομήνικος Θεοτοκόπουλος, pronounced [ðoˈminikos θeotoˈkopulos]; 1 October 1541 – 7 April 1614), most widely known as El Greco (Spanish pronunciation: [el ˈɣɾeko]; ""The Greek""), was a Greek painter, sculptor and architect of the Spanish Renaissance, regarded as one of the greatest artists of all time. El Greco was a nickname, and the artist normally signed his paintings with his full birth name in Greek letters often adding the word Κρής (Krḗs), which means ""Cretan"" in Ancient Greek.
|
||||
El Greco was born in the Kingdom of Candia (modern Crete), which was at that time part of the Republic of Venice, and the center of Post-Byzantine art. He trained and became a master within that tradition before traveling at age 26 to Venice, as other Greek artists had done. In 1570, he moved to Rome, where he opened a workshop and executed a series of works. During his stay in Italy, El Greco enriched his style with elements of Mannerism and of the Venetian Renaissance taken from a number of great artists of the time, notably Tintoretto and Titian. In 1577, he moved to Toledo, Spain, where he lived and worked until his death. In Toledo, El Greco received several major commissions and produced his best-known paintings, such as View of Toledo and Opening of the Fifth Seal.
|
||||
El Greco's dramatic and expressionistic style was met with puzzlement by his contemporaries but found appreciation by the 20th century. El Greco is regarded as a precursor of both Expressionism and Cubism, while his personality and works were a source of inspiration for poets and writers such as Rainer Maria Rilke and Nikos Kazantzakis. El Greco has been characterized by modern scholars as an artist so individual that he belongs to no conventional school. He is best known for tortuously elongated figures and often fantastic or phantasmagorical pigmentation, marrying Byzantine traditions with those of Western painting.","El Greco",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Spanish Renaissance:1},{Renaissance:2},{Mannerism:1},{Mannerism (Late Renaissance):170}"", ""last_year"": 1614, ""birth_year"": 1541, ""death_year"": 1614, ""first_year"": 1568, ""pupils_raw"": null, ""birth_place"": ""Heraklion"", ""citizenship"": ""Republic of Venice"", ""death_place"": ""Toledo"", ""nationality"": ""Spanish,Greek"", ""occupations"": ""['architect', 'painter', 'sculptor', 'architectural draftsperson']"", ""teachers_raw"": ""Titian,"", ""painting_school"": ""Cretan School"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": ""Byzantine Art,"", ""influenced_on_raw"": ""Expressionism,Cubism,Eugene Delacroix,Edouard Manet,Der Blaue Reiter (The Blue Rider),Paul Cezanne,Pablo Picasso,Franz Marc,Jackson Pollock,Roberto Montenegro,Jose Clemente Orozco,Francisco Pacheco,"", ""wikiart_pictures_count"": null}","portraits/thumbs/El_Greco_thumb.jpg"
|
||||
33,"Georges Braque",1882,1963,19,"portraits/Georges_Braque.jpg","Georges Braque ( BRA(H)K; French: [ʒɔʁʒ bʁak]; 13 May 1882 – 31 August 1963) was a major 20th-century French painter, collagist, draughtsman, printmaker and sculptor. His most notable contributions were in his alliance with Fauvism from 1905, and the role he played in the development of Cubism.","Georges Braque ( BRA(H)K; French: [ʒɔʁʒ bʁak]; 13 May 1882 – 31 August 1963) was a major 20th-century French painter, collagist, draughtsman, printmaker and sculptor. His most notable contributions were in his alliance with Fauvism from 1905, and the role he played in the development of Cubism. Braque's work between 1908 and 1912 is closely associated with that of his colleague Pablo Picasso. Their respective Cubist works were indistinguishable for many years, yet the quiet nature of Braque was partially eclipsed by the fame and notoriety of Picasso.","Georges Braque",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Analytical Cubism, Cubism, Expressionism, Fauvism, Impressionism, Post-Impressionism, Surrealism, Synthetic Cubism"", ""movement"": ""Cubism"", ""last_year"": 1938, ""birth_year"": 1882, ""death_year"": 1963, ""first_year"": 1906, ""pupils_raw"": null, ""birth_place"": ""Argenteuil"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""engraver, painter, sculptor, graphic artist, jewelry designer, scenographer, designer, printmaker, architectural draftsperson, drawer, lithographer, collagist"", ""teachers_raw"": null, ""painting_school"": ""École de Paris,Les Fauves"", ""styles_extended"": ""{Analytical Cubism:38},{Cubism:32},{Expressionism:95},{Fauvism:24},{Impressionism:1},{Post-Impressionism:3},{Surrealism:6},{Synthetic Cubism:45}"", ""art500k_movements"": ""{Cubism:243}"", ""influenced_by_raw"": ""Nicolas Poussin,"", ""influenced_on_raw"": ""David Alfaro Siqueiros,Vilhelm Lundstrom,Mikhail Larionov,Carlos Nadal,"", ""wikiart_pictures_count"": 244}","portraits/thumbs/Georges_Braque_thumb.jpg"
|
||||
67,"Maurice de Vlaminck",1876,1958,17,"portraits/Maurice_de_Vlaminck.jpg","Maurice de Vlaminck (French: [vlamɛ̃k]; 4 April 1876 – 11 October 1958) was a French painter. Along with André Derain and Henri Matisse, he is considered one of the principal figures in the Fauve movement, a group of modern artists who from 1904 to 1908 were united in their use of intense colour.","Maurice de Vlaminck (French: [vlamɛ̃k]; 4 April 1876 – 11 October 1958) was a French painter. Along with André Derain and Henri Matisse, he is considered one of the principal figures in the Fauve movement, a group of modern artists who from 1904 to 1908 were united in their use of intense colour. Vlaminck was one of the Fauves at the controversial Salon d'Automne exhibition of 1905.","Maurice de Vlaminck",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Fauvism, Neo-Impressionism, Post-Impressionism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1958, ""birth_year"": 1876, ""death_year"": 1958, ""first_year"": 1900, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Rueil-la-Gadelière"", ""nationality"": ""French"", ""occupations"": ""drawer, printmaker, ceramicist, sculptor, painter, poet, writer"", ""teachers_raw"": null, ""painting_school"": ""Les Fauves"", ""styles_extended"": ""{Cubism:6},{Fauvism:15},{Neo-Impressionism:14},{Post-Impressionism:39},{Unknown:1}"", ""art500k_movements"": ""{Fauvism:1},{Modern art:1},{Post-Impressionism:74}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Guan Zilan,"", ""wikiart_pictures_count"": 75}","portraits/thumbs/Maurice_de_Vlaminck_thumb.jpg"
|
||||
37,"Giorgio de Chirico",1888,1978,24,"portraits/Giorgio_de_Chirico.jpg","Giuseppe Maria Alberto Giorgio de Chirico ( KIRR-ik-oh; Italian: [ˈdʒordʒo de ˈkiːriko]; 10 July 1888 – 20 November 1978) was a Greek-Italian artist and writer born in Volos, Greece. In the years before World War I, he founded the scuola metafisica art movement, which profoundly influenced the surrealists.","Giuseppe Maria Alberto Giorgio de Chirico ( KIRR-ik-oh; Italian: [ˈdʒordʒo de ˈkiːriko]; 10 July 1888 – 20 November 1978) was a Greek-Italian artist and writer born in Volos, Greece. In the years before World War I, he founded the scuola metafisica art movement, which profoundly influenced the surrealists. His best-known works often feature Roman arcades, long shadows, mannequins, trains, and illogical perspective. His imagery reflects his affinity for the philosophy of Arthur Schopenhauer and of Friedrich Nietzsche, and for the mythology of his birthplace.
|
||||
After 1919, de Chirico became a critic of modern art, studied traditional painting techniques, and later worked in a neoclassical or neo-Baroque style, while frequently revisiting the metaphysical themes of his earlier work.","Giorgio de Chirico",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Metaphysical art, Neo-baroque, Surrealism, Unknown"", ""movement"": ""Metaphysical art"", ""last_year"": 2006, ""birth_year"": 1888, ""death_year"": 1978, ""first_year"": 1908, ""pupils_raw"": null, ""birth_place"": ""Volos"", ""citizenship"": ""Italy"", ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""drawer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Metaphysical art:96},{Neo-baroque:100},{Surrealism:1},{Unknown:1}"", ""art500k_movements"": ""{Surrealism,Metaphysical art:197}"", ""influenced_by_raw"": ""Raphael,Arnold Böcklin,Alfred Kubin,Max Klinger,"", ""influenced_on_raw"": ""Salvador Dali,Rene Magritte,Carlo Carra,Metaphysical art,Surrealism,Lettrism,Yves Tanguy,George Ault,Giorgio Morandi,Neo Rauch,"", ""wikiart_pictures_count"": 198}","portraits/thumbs/Giorgio_de_Chirico_thumb.jpg"
|
||||
53,"Jean-Antoine Watteau",1684,1721,9,"portraits/Jean-Antoine_Watteau.jpg","Jean-Antoine Watteau (UK: , US: , French: [ʒɑ̃ ɑ̃twan vato]; baptised 10 October 1684 – died 18 July 1721) was a French painter and draughtsman whose brief career spurred the revival of interest in colour and movement, as seen in the tradition of Correggio and Rubens. He revitalized the waning Baroque style, shifting it to the less severe, more naturalistic, less formally classical, Rococo.","Jean-Antoine Watteau (UK: , US: , French: [ʒɑ̃ ɑ̃twan vato]; baptised 10 October 1684 – died 18 July 1721) was a French painter and draughtsman whose brief career spurred the revival of interest in colour and movement, as seen in the tradition of Correggio and Rubens. He revitalized the waning Baroque style, shifting it to the less severe, more naturalistic, less formally classical, Rococo. Watteau is credited with inventing the genre of fêtes galantes, scenes of bucolic and idyllic charm, suffused with a theatrical air. Some of his best known subjects were drawn from the world of Italian comedy and ballet.","Antoine Watteau",1600,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": null, ""last_year"": null, ""birth_year"": 1684, ""death_year"": 1721, ""first_year"": null, ""pupils_raw"": null, ""birth_place"": ""Valenciennes"", ""citizenship"": ""France"", ""death_place"": ""Nogent-sur-Marne"", ""nationality"": null, ""occupations"": ""['artist', 'painter', 'graphic artist', 'printmaker', 'architectural draftsperson', 'drawer']"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": null}","portraits/thumbs/Jean-Antoine_Watteau_thumb.jpg"
|
||||
96,"Wassily Kandinsky",1866,1944,18,"portraits/Wassily_Kandinsky.jpg","S. 4 December] 1866 – 13 December 1944) was a Russian painter and art theorist active in Germany during the late Belle Époque and Interwar eras.","Wassily Wassilyevich Kandinsky (16 December [O.S. 4 December] 1866 – 13 December 1944) was a Russian painter and art theorist active in Germany during the late Belle Époque and Interwar eras. Kandinsky is generally credited as one of the pioneers of abstraction in Western art. Born in Moscow, he began painting studies (life-drawing, sketching and anatomy) at the age of 30.
|
||||
In 1896, Kandinsky settled in Munich, studying first at Anton Ažbe's private school and then at the Academy of Fine Arts. During this time, he was first the teacher and then the partner of German artist Gabriele Münter. He returned to Moscow in 1914 after the outbreak of World War I. Following the Russian Revolution, Kandinsky ""became an insider in the cultural administration of Anatoly Lunacharsky"" and helped establish the Museum of the Culture of Painting. However, by then, ""his spiritual outlook... was foreign to the argumentative materialism of Soviet society"" and opportunities beckoned in Germany, to which he returned in 1920. There, he taught at the Bauhaus school of art and architecture from 1922 until the Nazis closed it in 1933. He then moved to France, where he lived for the rest of his life, becoming a French citizen in 1939 and producing some of his most prominent art. He died in Neuilly-sur-Seine in 1944.","Wassily Kandinsky",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Abstract Expressionism, Art Nouveau (Modern), Expressionism, Impressionism, Post-Impressionism, Realism"", ""movement"": ""Expressionism"", ""last_year"": 1942, ""birth_year"": 1866, ""death_year"": 1944, ""first_year"": 1898, ""pupils_raw"": null, ""birth_place"": ""Moscow"", ""citizenship"": ""France"", ""death_place"": ""Neuilly-sur-Seine"", ""nationality"": ""Russian"", ""occupations"": ""teacher, lawyer, illustrator, painter, sculptor, university teacher, graphic artist, scenographer, designer, drawer, art theorist, printmaker, architectural draftsperson"", ""teachers_raw"": null, ""painting_school"": ""Blue Rose,Der Blaue Reiter (The Blue Rider),Bauhaus,Degenerate art,Der Sturm"", ""styles_extended"": ""{Abstract Art:141},{Abstract Expressionism:1},{Art Nouveau (Modern):3},{Expressionism:57},{Impressionism:1},{Post-Impressionism:23},{Realism:2}"", ""art500k_movements"": ""{Expressionism,Abstract Art:220}"", ""influenced_by_raw"": ""Edvard Munch,Aristarkh Lentulov,Robert Delaunay,Paul Cezanne,Claude Monet,"", ""influenced_on_raw"": ""Josef Albers,Egon Schiele,Georgia O'Keeffe,Karl Otto Gotz,Nasreen Mohamedi,William Baziotes,Arshile Gorky,Hans Hofmann,"", ""wikiart_pictures_count"": 228}","portraits/thumbs/Wassily_Kandinsky_thumb.jpg"
|
||||
35,"Giacomo Balla",1871,1958,20,"portraits/Giacomo_Balla.png","Giacomo Balla (18 July 1871 – 1 March 1958) was an Italian painter, art teacher and poet best known as a key proponent of Futurism. In his paintings, he depicted light, movement and speed.","Giacomo Balla (18 July 1871 – 1 March 1958) was an Italian painter, art teacher and poet best known as a key proponent of Futurism. In his paintings, he depicted light, movement and speed. He was concerned with expressing movement in his works, but unlike other leading futurists he was not interested in machines or violence with his works tending towards the witty and whimsical.","Giacomo Balla",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Divisionism, Futurism, Impressionism, Pointillism"", ""movement"": ""Futurism"", ""last_year"": 1946, ""birth_year"": 1871, ""death_year"": 1958, ""first_year"": 1907, ""pupils_raw"": ""Mario Sironi,"", ""birth_place"": ""Turin"", ""citizenship"": ""Italy"", ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""composer, teacher, painter, sculptor, ceramicist, drawer"", ""teachers_raw"": ""Artists2/Giacomo Balla/Future 1923.jpg,Artists2/Giacomo Balla/Spirit Form Transformation 1918.jpg,Artists2/Giacomo Balla/Primavera Spring 1918.jpg,Artists2/Giacomo Balla/Lines Force Of Boccioni S Fist.jpg,Artists2/Giacomo Balla/Flags For The Altar 1915.jpg,Artists2/Giacomo Balla/Synthesis Of Movement.jpg,Artists2/Giacomo Balla/Iridescent Interpenetration No 5 Eucalyptus 1914.jpg,Artists2/Giacomo Balla/Iridescent Interpenetration No 13.jpg,Artists2/Giacomo Balla/Dynamic Of Boccioni S Fist.jpg,Artists2/Giacomo Balla/Iridescent Interpenetration 1913.jpg,Artists2/Giacomo Balla/Abstract Speed 1913.jpg,"", ""painting_school"": ""Milan Futurist Group"", ""styles_extended"": ""{Abstract Art:6},{Divisionism:1},{Futurism:58},{Impressionism:2},{Pointillism:3}"", ""art500k_movements"": ""{Modern art:8},{Abstract art:2},{Futurism:76}"", ""influenced_by_raw"": ""Mario Sironi,posters-and-advertisements,logotypes,designs-and-sketches,allegories-and-symbols,stars-and-planets,roads-and-vehicles,instruments-and-mechanisms,children,games-and-sport,male-portraits,famous-people,allegories-and-symbols,doors-and-windows,Paris,carousels-and-attractions,night,"", ""influenced_on_raw"": ""Futuro,Transformación forma-espíritu,Compenetrazione Iridescente No.13,Private Collection,Paesaggio,"", ""wikiart_pictures_count"": 70}","portraits/thumbs/Giacomo_Balla_thumb.jpg"
|
||||
42,"Hannah Höch",1889,1978,23,"portraits/Hannah_H_ch.jpg","Hannah Höch (German: [hœç]; 1 November 1889 – 31 May 1978) was a German Dada artist. She is best known for her work of the Weimar period, when she was one of the originators of photomontage.","Hannah Höch (German: [hœç]; 1 November 1889 – 31 May 1978) was a German Dada artist. She is best known for her work of the Weimar period, when she was one of the originators of photomontage. Photomontage, or fotomontage, is a type of collage in which the pasted items are actual photographs, or photographic reproductions pulled from the press and other widely produced media.
|
||||
An important element in Höch's work was the intention to dismantle the fable and dichotomy that existed in the concept of the ""New Woman"": an energetic, professional, and androgynous woman, who is ready to take her place as man's equal. Her interest in the topic was in how the dichotomy was structured, as well as in who structures social roles.
|
||||
Other key themes in Höch's works were androgyny, political discourse, and shifting gender roles. These themes all interacted to create a feminist discourse surrounding Höch's works, which encouraged the liberation and agency of women during the Weimar Republic (1919–1933) and continuing through to today.","Hannah Höch",1800,False,False,"{""gender"": ""female"", ""source"": ""PainterPalette.csv"", ""styles"": ""Dada"", ""movement"": ""Dada"", ""last_year"": 1969, ""birth_year"": 1889, ""death_year"": 1978, ""first_year"": 1919, ""pupils_raw"": null, ""birth_place"": ""Gotha"", ""citizenship"": ""Germany"", ""death_place"": ""Berlin"", ""nationality"": ""German"", ""occupations"": ""photographer, artist, painter, graphic artist, drawer, collagist"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Dada:13}"", ""art500k_movements"": ""{Dada:13}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 13}","portraits/thumbs/Hannah_H_ch_thumb.jpg"
|
||||
72,"Olga Rozanova",1886,1918,21,"portraits/Olga_Rozanova.jpg","Olga Vladimirovna Rozanova (also spelled Rosanova, Russian: Ольга Владимировна Розанова) (22 June 1886 – 7 November 1918, Moscow) was a Russian avant-garde artist painting in the styles of Suprematism, Neo-Primitivism, and Cubo-Futurism.","Olga Vladimirovna Rozanova (also spelled Rosanova, Russian: Ольга Владимировна Розанова) (22 June 1886 – 7 November 1918, Moscow) was a Russian avant-garde artist painting in the styles of Suprematism, Neo-Primitivism, and Cubo-Futurism.","Olga Rozanova",1800,False,False,"{""gender"": ""female"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Cubo-Futurism, Naïve Art (Primitivism), Suprematism, Unknown"", ""movement"": ""Suprematism"", ""last_year"": 1918, ""birth_year"": 1886, ""death_year"": 1918, ""first_year"": 1910, ""pupils_raw"": null, ""birth_place"": ""Melenki"", ""citizenship"": ""Russian Empire"", ""death_place"": ""Botkin Hospital"", ""nationality"": ""Russian"", ""occupations"": ""collagist, drawer, textile artist, visual artist, painter, illustrator, artist, poet, writer"", ""teachers_raw"": null, ""painting_school"": ""Supremus,Soyuz Molodyozhi (Union of the Youth)"", ""styles_extended"": ""{Cubism:23},{Cubo-Futurism:9},{Naïve Art (Primitivism):2},{Suprematism:13},{Unknown:5}"", ""art500k_movements"": ""{Cubo-Futurism,Suprematism:53}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 52}","portraits/thumbs/Olga_Rozanova_thumb.jpg"
|
||||
43,"Henri de Toulouse-Lautrec",1864,1901,14,"portraits/Henri_de_Toulouse-Lautrec.jpg","Comte Henri Marie Raymond de Toulouse-Lautrec-Montfa (24 November 1864 – 9 September 1901), known as Toulouse-Lautrec (French: [tuluz lotʁɛk]), was a French painter, printmaker, draughtsman, caricaturist, and illustrator. His immersion in the colourful and theatrical life of Paris' Belle Époque in the late 19th century allowed him to produce popular works of art from decadent affairs.","Comte Henri Marie Raymond de Toulouse-Lautrec-Montfa (24 November 1864 – 9 September 1901), known as Toulouse-Lautrec (French: [tuluz lotʁɛk]), was a French painter, printmaker, draughtsman, caricaturist, and illustrator. His immersion in the colourful and theatrical life of Paris' Belle Époque in the late 19th century allowed him to produce popular works of art from decadent affairs.
|
||||
Born into the aristocracy, Toulouse-Lautrec broke both his legs during adolescence, leaving him with a stunted appearance. In later life, he developed an affinity for brothels and prostitutes that directed the subject matter for many of his works, which record details of the late-19th-century bohemian lifestyle in Paris. He is among the painters described as Post-Impressionists, with Paul Cézanne, Vincent van Gogh, Paul Gauguin, and Georges Seurat also commonly considered as belonging in this loose group.","Henri de Toulouse-Lautrec",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern), Impressionism, Post-Impressionism, Realism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1901, ""birth_year"": 1864, ""death_year"": 1901, ""first_year"": 1880, ""pupils_raw"": null, ""birth_place"": ""Albi"", ""citizenship"": ""France"", ""death_place"": ""Saint-André-du-Bois"", ""nationality"": null, ""occupations"": ""graphic designer, poster artist, painter, graphic artist, designer, ceramicist, printmaker, architectural draftsperson, lithographer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Art Nouveau (Modern):112},{Impressionism:32},{Post-Impressionism:221},{Realism:7},{Unknown:1}"", ""art500k_movements"": ""{Post-Impressionism:14},{Art Nouveau:13},{Modern art:14}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 373}","portraits/thumbs/Henri_de_Toulouse-Lautrec_thumb.jpg"
|
||||
75,"Parrhasius",-450,-380,1,"portraits/Parrhasius.jpg","Parrhasius of Ephesus (Ancient Greek: Παρράσιος) was a famed painter of Ancient Greece. Active during an era when other painters were starting to depict realistic scenes with shading, Parrhasius stood out by sticking to the older technique of line-drawing, and innovated new ways to portray volume using this method.","Parrhasius of Ephesus (Ancient Greek: Παρράσιος) was a famed painter of Ancient Greece. Active during an era when other painters were starting to depict realistic scenes with shading, Parrhasius stood out by sticking to the older technique of line-drawing, and innovated new ways to portray volume using this method. He created new iconographies that future painters would reproduce for centuries.","Parrhasius (painter)",-500,False,False,NULL,"portraits/thumbs/Parrhasius_thumb.jpg"
|
||||
74,"Parmigianino",1503,1540,7,"portraits/Parmigianino.jpg","Girolamo Francesco Maria Mazzola (11 January 1503 – 24 August 1540), also known as Francesco Mazzola or, more commonly, as Parmigianino (UK: , US: , Italian: [parmidʒaˈniːno]; ""the little one from Parma""), was an Italian Mannerist painter and printmaker active in Florence, Rome, Bologna, and his native city of Parma. His work is characterized by a ""refined sensuality"" and often elongation of forms and includes Vision of Saint Jerome (1527) and the iconic if somewhat anomalous Madonna with the Long Neck (1534), and he remains the best known artist of the first generation whose whole careers fall into the Mannerist period.","Girolamo Francesco Maria Mazzola (11 January 1503 – 24 August 1540), also known as Francesco Mazzola or, more commonly, as Parmigianino (UK: , US: , Italian: [parmidʒaˈniːno]; ""the little one from Parma""), was an Italian Mannerist painter and printmaker active in Florence, Rome, Bologna, and his native city of Parma. His work is characterized by a ""refined sensuality"" and often elongation of forms and includes Vision of Saint Jerome (1527) and the iconic if somewhat anomalous Madonna with the Long Neck (1534), and he remains the best known artist of the first generation whose whole careers fall into the Mannerist period.
|
||||
His prodigious and individual talent has always been recognised, but his career was disrupted by war, especially the Sack of Rome in 1527, three years after he moved there, and then ended by his death at 37. He produced outstanding drawings, and was one of the first Italian painters to experiment with printmaking himself. While his portable works have always been keenly collected and are now in major museums in Italy and around the world, his two large projects in fresco are in a church in Parma and a palace in a small town nearby. This in conjunction with their lack of large main subjects has resulted in their being less well known than other works by similar artists. He painted a number of important portraits, leading a trend in Italy towards the three-quarters or full-length figure, previously mostly reserved for royalty.","Parmigianino",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Italian Renaissance:7},{Renaissance:7},{Mannerism:14},{Mannerism (Late Renaissance):46}"", ""last_year"": 1540, ""birth_year"": 1503, ""death_year"": 1540, ""first_year"": 1520, ""pupils_raw"": null, ""birth_place"": ""Parma"", ""citizenship"": ""Italy"", ""death_place"": ""Casalmaggiore"", ""nationality"": ""Italian"", ""occupations"": ""['painter', 'graphic artist', 'etcher', 'printmaker', 'architectural draftsperson', 'drawer']"", ""teachers_raw"": null, ""painting_school"": ""Parma school"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": ""Albrecht Durer,"", ""influenced_on_raw"": ""Jacopo Bassano,"", ""wikiart_pictures_count"": null}","portraits/thumbs/Parmigianino_thumb.jpg"
|
||||
78,"Peter Paul Rubens",1577,1640,8,"portraits/Peter_Paul_Rubens.jpg","Sir Peter Paul Rubens ( ROO-bənz; Dutch: [ˈpeːtər pʌul ˈrybəns]; 28 June 1577 – 30 May 1640) was a Flemish artist and diplomat. He is considered the most influential artist of the Flemish Baroque tradition.","Sir Peter Paul Rubens ( ROO-bənz; Dutch: [ˈpeːtər pʌul ˈrybəns]; 28 June 1577 – 30 May 1640) was a Flemish artist and diplomat. He is considered the most influential artist of the Flemish Baroque tradition. Rubens's highly charged compositions reference erudite aspects of classical and Christian history. His unique and immensely popular Baroque style emphasised movement, colour, and sensuality, which followed the immediate, dramatic artistic style promoted in the Counter-Reformation. Rubens was a painter producing altarpieces, portraits, landscapes, and history paintings of mythological and allegorical subjects. He was also a prolific designer of cartoons for the Flemish tapestry workshops and of frontispieces for the publishers in Antwerp.
|
||||
Rubens was born and raised in the Holy Roman Empire (modern-day Germany) to parents who were refugees from Antwerp in the Duchy of Brabant in the Spanish Netherlands and moved to Antwerp at about 12. In addition to running a large workshop in Antwerp that produced paintings popular with nobility and art collectors throughout Europe, Rubens was a classically educated humanist scholar and diplomat who was knighted by both Philip IV of Spain and Charles I of England. Rubens was a prolific artist. The catalogue of his works by Michael Jaffé lists 1,403 pieces, excluding numerous copies made in his workshop.
|
||||
His commissioned works were mostly history paintings, which included religious and mythological subjects, and hunt scenes. He painted portraits, especially of friends, and self-portraits, and in later life painted several landscapes. Rubens designed tapestries and prints, as well as his own house. He also oversaw the ephemeral decorations of the royal entry into Antwerp by the Cardinal-Infante Ferdinand of Austria in 1635. He wrote a book with illustrations of the palaces in Genoa, which was published in 1622 as Palazzi di Genova. The book was influential in spreading the Genoese palace style in Northern Europe. Rubens was an avid art collector and had one of the largest collections of art and books in Antwerp. He was also an art dealer and is known to have sold important art objects to George Villiers, 1st Duke of Buckingham.
|
||||
He was one of the last major artists to make consistent use of wooden panels as a support medium, even for very large works, but used canvas as well, especially when the work needed to be sent a long distance. For altarpieces, he sometimes painted on slate to reduce reflection problems.","Peter Paul Rubens",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1640, ""birth_year"": 1577, ""death_year"": 1640, ""first_year"": 1597, ""pupils_raw"": ""Anthony van Dyck,Jacob Jordaens,Frans Snyders,"", ""birth_place"": ""Siegen"", ""citizenship"": ""Spanish Netherlands"", ""death_place"": ""Antwerp"", ""nationality"": ""Flemish"", ""occupations"": ""graphic artist, printmaker, architectural draftsperson, diplomat, painter, sculptor"", ""teachers_raw"": ""Tobias Verhaecht,Adam van Noort,Otto van Veen,"", ""painting_school"": ""Flemish School,Antwerp School"", ""styles_extended"": ""{Baroque:559},{Unknown:55}"", ""art500k_movements"": ""{Antwerp school:3},{Baroque:390}"", ""influenced_by_raw"": ""Titian,Pieter Bruegel the Elder,Caravaggio,Hans Holbein the Younger,"", ""influenced_on_raw"": ""Gustave Courbet,Frans Hals,Rembrandt,John Constable,Théodore Géricault,Eugene Delacroix,Rosa Bonheur,Jenny Saville,Claudio Coello,"", ""wikiart_pictures_count"": 614}","portraits/thumbs/Peter_Paul_Rubens_thumb.jpg"
|
||||
48,"Ivan Klyun",1873,1943,21,"portraits/Ivan_Klyun.jpg","Ivan Vasilievich Kliun, or Klyun, born Klyunkov (Russian: Иван Васильевич Клюн; 1 September 1873, in Bolshiye Gorky, Petushinsky District – 13 December 1943, in Moscow) was a Russian Avant-Garde painter, sculptor and art theorist, associated with the Suprematist movement.","Ivan Vasilievich Kliun, or Klyun, born Klyunkov (Russian: Иван Васильевич Клюн; 1 September 1873, in Bolshiye Gorky, Petushinsky District – 13 December 1943, in Moscow) was a Russian Avant-Garde painter, sculptor and art theorist, associated with the Suprematist movement.","Ivan Kliun",1800,False,False,NULL,"portraits/thumbs/Ivan_Klyun_thumb.jpg"
|
||||
40,"Gustav Klimt",1862,1918,15,"portraits/Gustav_Klimt.jpg","Gustav Klimt (14 July 1862 – 6 February 1918) was an Austrian symbolist painter and a founding member of the Vienna Secession movement. His work helped define the Art Nouveau style in Europe.","Gustav Klimt (14 July 1862 – 6 February 1918) was an Austrian symbolist painter and a founding member of the Vienna Secession movement. His work helped define the Art Nouveau style in Europe. Klimt is known for his paintings, murals, sketches, and other objets d'art. Klimt's primary subject was the female body, and his works are marked by a frank eroticism. Amongst his figurative works, which include allegories and portraits, he painted landscapes. He is best known for The Kiss and Portrait of Adele Bloch-Bauer I. Among the artists of the Vienna Secession, Klimt was the most influenced by Japanese art and its methods.
|
||||
Early in his career, he was a successful painter of architectural decorations in a conventional manner. As he began to develop a more personal style, his work was the subject of controversy that culminated when the paintings he completed around 1900 for the ceiling of the Great Hall of the University of Vienna were criticised as pornographic. He subsequently accepted no more public commissions, but achieved a new success with the paintings of his ""golden phase"", many of which include gold leaf. Klimt's work was an important influence on his younger peer Egon Schiele.
|
||||
Klimt died in 1918, having suffered from a stroke and pneumonia. Since his death, Klimt's paintings have brought some of the highest prices recorded for individual works of art at auction.","Gustav Klimt",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern), Japonism, Neoclassicism, Realism, Romanticism, Symbolism, Unknown"", ""movement"": ""Symbolism"", ""last_year"": 1918, ""birth_year"": 1862, ""death_year"": 1918, ""first_year"": 1879, ""pupils_raw"": ""Fedir Krychevsky,"", ""birth_place"": ""Vienna"", ""citizenship"": ""Cisleithania"", ""death_place"": ""Vienna"", ""nationality"": ""Austrian"", ""occupations"": ""drawer, architectural draftsperson, designer, graphic artist, painter"", ""teachers_raw"": null, ""painting_school"": ""Vienna Secession"", ""styles_extended"": ""{Art Nouveau (Modern):86},{Japonism:6},{Neoclassicism:3},{Realism:5},{Romanticism:2},{Symbolism:50},{Unknown:9}"", ""art500k_movements"": ""{Symbolism,Art Nouveau:162}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Egon Schiele,Oskar Kokoschka,Theo van Doesburg,Art Nouveau,Fedir Krychevsky,Victor Zaretsky,"", ""wikiart_pictures_count"": 161}","portraits/thumbs/Gustav_Klimt_thumb.jpg"
|
||||
46,"Hieronymus Bosch",1450,1516,5,"portraits/Hieronymus_Bosch.jpg","Hieronymus Bosch (; Dutch: [ɦijeːˈroːnimʏz ˈbɔs] ; born Jheronimus van Aken [jeːˈroːnimʏs fɑn ˈaːkə(n)]; c. 1450 – 9 August 1516) was a Dutch painter from Brabant.","Hieronymus Bosch (; Dutch: [ɦijeːˈroːnimʏz ˈbɔs] ; born Jheronimus van Aken [jeːˈroːnimʏs fɑn ˈaːkə(n)]; c. 1450 – 9 August 1516) was a Dutch painter from Brabant. He is one of the most notable representatives of the Early Netherlandish painting school. His work, generally oil on oak wood, mainly contains fantastic illustrations of religious concepts and narratives. Within his lifetime, his work was collected in the Netherlands, Austria, and Spain, and widely copied, especially his macabre and nightmarish depictions of hell.
|
||||
Little is known of Bosch's life, though there are some records. He spent most of it in the town of 's-Hertogenbosch, where he was born in his grandfather's house. The roots of his forefathers are in Nijmegen and Aachen (reflected in his surname, Van Aken). His original, fantastical style cast a wide influence on northern art of the 16th century; Pieter Bruegel the Elder was his best-known follower. Today, Bosch is seen as a highly individualistic artist who offered profound insights into humanity's desires and deepest fears. Attribution of his work has been especially difficult; today only about 25 paintings are confidently identified as his, along with eight drawings. About another half-dozen paintings are confidently attributed to his workshop. His most acclaimed works consist of three triptych altarpieces, particularly The Garden of Earthly Delights.","Hieronymus Bosch",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Northern Renaissance, Unknown"", ""movement"": ""Northern Renaissance"", ""last_year"": 1575, ""birth_year"": 1450, ""death_year"": 1516, ""first_year"": 1450, ""pupils_raw"": null, ""birth_place"": ""'s-Hertogenbosch"", ""citizenship"": ""Southern Netherlands"", ""death_place"": ""'s-Hertogenbosch"", ""nationality"": ""Dutch"", ""occupations"": ""painter, visual artist, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": ""Flemish School"", ""styles_extended"": ""{Northern Renaissance:158},{Unknown:25}"", ""art500k_movements"": ""{Northern Renaissance:184}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Pieter Bruegel the Elder,Yves Tanguy,"", ""wikiart_pictures_count"": 183}","portraits/thumbs/Hieronymus_Bosch_thumb.jpg"
|
||||
85,"Richard Hamilton",1922,2011,26,"portraits/Richard_Hamilton.jpg","Richard William Hamilton (24 February 1922 – 13 September 2011) was an English painter and collage artist. , produced for the This Is Tomorrow exhibition of the Independent Group in London, are considered by critics and historians to be among the earliest works of pop art.","Richard William Hamilton (24 February 1922 – 13 September 2011) was an English painter and collage artist. His 1955 exhibition Man, Machine and Motion (Hatton Gallery, Newcastle upon Tyne) and his 1956 collage Just what is it that makes today's homes so different, so appealing?, produced for the This Is Tomorrow exhibition of the Independent Group in London, are considered by critics and historians to be among the earliest works of pop art. A major retrospective of his work was at Tate Modern in 2014.","Richard Hamilton (artist)",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Pop Art, Unknown"", ""movement"": ""Pop Art"", ""last_year"": 2008, ""birth_year"": 1922, ""death_year"": 2011, ""first_year"": 1956, ""pupils_raw"": null, ""birth_place"": ""London"", ""citizenship"": ""United Kingdom"", ""death_place"": ""London"", ""nationality"": ""British"", ""occupations"": ""photographer, painter, drawer, collagist, computer artist"", ""teachers_raw"": null, ""painting_school"": ""Independent Group"", ""styles_extended"": ""{Pop Art:38},{Unknown:27}"", ""art500k_movements"": ""{Pop Art:65}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 65}","portraits/thumbs/Richard_Hamilton_thumb.jpg"
|
||||
50,"Jackson Pollock",1912,1956,25,"portraits/Jackson_Pollock.jpg","Paul Jackson Pollock (; January 28, 1912 – August 11, 1956) was an American painter. A major figure in the abstract expressionist movement, he was widely noticed for his ""drip technique"" of pouring or splashing liquid household paint onto a horizontal surface, enabling him to view and paint his canvases from all angles.","Paul Jackson Pollock (; January 28, 1912 – August 11, 1956) was an American painter. A major figure in the abstract expressionist movement, he was widely noticed for his ""drip technique"" of pouring or splashing liquid household paint onto a horizontal surface, enabling him to view and paint his canvases from all angles. It was called all-over painting and action painting, because Pollock covered the entire canvas and used the force of his whole body to paint, often in a frenetic dancing style. This extreme form of abstraction divided critics: some praised the immediacy of the creation, while others derided the random effects.
|
||||
A reclusive and volatile personality, Pollock struggled with alcoholism for most of his life. In 1945, he married artist Lee Krasner, who became an important influence on his career and on his legacy. Pollock died in August 1956 at age 44 in an alcohol-related single-car collision when he was driving. Four months after his death, Pollock was given a memorial retrospective exhibition at the Museum of Modern Art (MoMA) in New York City. A larger, more comprehensive exhibition of his work was held there in 1967. In 1998 and 1999, Pollock's work was honored with large-scale retrospective exhibitions at MoMA and the Tate Gallery in London.","Jackson Pollock",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Expressionism, Action painting, Expressionism"", ""movement"": ""Abstract Art"", ""last_year"": 1953, ""birth_year"": 1912, ""death_year"": 1956, ""first_year"": 1934, ""pupils_raw"": null, ""birth_place"": ""Cody"", ""citizenship"": ""United States of America"", ""death_place"": ""East Hampton"", ""nationality"": ""American"", ""occupations"": ""painter, printmaker, drawer"", ""teachers_raw"": null, ""painting_school"": ""New York School,Irascibles"", ""styles_extended"": ""{Abstract Expressionism:33},{Action painting:43},{Expressionism:14}"", ""art500k_movements"": ""{Modern art:1},{Abstract Expressionism:84}"", ""influenced_by_raw"": ""El Greco,Pablo Picasso,Paul Cezanne,Thomas Hart Benton,Jose Clemente Orozco,Joan Miro,Chaim Soutine,David Alfaro Siqueiros,Salvador Dali,Yves Tanguy,Native Art,Regionalism,Surrealism,"", ""influenced_on_raw"": ""Helen Frankenthaler,Cy Twombly,Morris Louis,Lee Krasner,Robert Morris,Kenneth Noland,Franz Kline,Gerhard Richter,"", ""wikiart_pictures_count"": 90}","portraits/thumbs/Jackson_Pollock_thumb.jpg"
|
||||
55,"Jean-Baptiste-Camille Corot",1796,1875,12,"portraits/Jean-Baptiste-Camille_Corot.jpg","Jean-Baptiste-Camille Corot (UK: KORR-oh, US: kə-ROH, kor-OH; French: [ʒɑ̃ batist kamij kɔʁo]; 16 July 1796 – 22 February 1875), or simply Camille Corot, was a French landscape and portrait painter as well as a printmaker in etching. A pivotal figure in landscape painting, his vast output simultaneously referenced the Neo-Classical tradition and anticipated the plein-air innovations of Impressionism.","Jean-Baptiste-Camille Corot (UK: KORR-oh, US: kə-ROH, kor-OH; French: [ʒɑ̃ batist kamij kɔʁo]; 16 July 1796 – 22 February 1875), or simply Camille Corot, was a French landscape and portrait painter as well as a printmaker in etching. A pivotal figure in landscape painting, his vast output simultaneously referenced the Neo-Classical tradition and anticipated the plein-air innovations of Impressionism.","Jean-Baptiste-Camille Corot",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": null, ""last_year"": 1875, ""birth_year"": 1796, ""death_year"": 1875, ""first_year"": 1825, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": null, ""occupations"": ""['photographer', 'painter', 'printmaker', 'architectural draftsperson', 'drawer']"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": null}","portraits/thumbs/Jean-Baptiste-Camille_Corot_thumb.jpg"
|
||||
56,"Jean-François Millet",1814,1875,12,"portraits/Jean-Fran_ois_Millet.jpg","Jean-François Millet (French pronunciation: [ʒɑ̃ fʁɑ̃swa milɛ]; 4 October 1814 – 20 January 1875) was a French painter and one of the founders of the Barbizon school in rural France. Millet is noted for his paintings of peasant farmers and can be categorized as part of the Realism art movement.","Jean-François Millet (French pronunciation: [ʒɑ̃ fʁɑ̃swa milɛ]; 4 October 1814 – 20 January 1875) was a French painter and one of the founders of the Barbizon school in rural France. Millet is noted for his paintings of peasant farmers and can be categorized as part of the Realism art movement. Toward the end of his career, he became increasingly interested in painting pure landscapes. He is known best for his oil paintings but is also noted for his pastels, Conté crayon drawings, and etchings.","Jean-François Millet",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Realism:128}"", ""last_year"": 1875, ""birth_year"": 1814, ""death_year"": 1875, ""first_year"": 1839, ""pupils_raw"": null, ""birth_place"": ""Gréville-Hague"", ""citizenship"": ""France"", ""death_place"": ""Barbizon"", ""nationality"": ""French"", ""occupations"": ""['photographer', 'engraver', 'painter', 'graphic artist', 'plower', 'architectural draftsperson']"", ""teachers_raw"": null, ""painting_school"": ""Barbizon school"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": ""Honore Daumier,Michelangelo,Nicolas Poussin,"", ""influenced_on_raw"": ""Edward Mitchell Bannister,Andres de Santa Maria,Vincent van Gogh,Claude Monet,Georges Seurat,Salvador Dali,Eugène Burnand,Giovanni Segantini,Albin Egger-Lienz,"", ""wikiart_pictures_count"": null}","portraits/thumbs/Jean-Fran_ois_Millet_thumb.jpg"
|
||||
62,"Leonardo da Vinci",1452,1519,6,"portraits/Leonardo_da_Vinci.png","Leonardo di ser Piero da Vinci (15 April 1452 – 2 May 1519) was an Italian polymath of the High Renaissance who was active as a painter, draughtsman, engineer, scientist, theorist, sculptor, and architect. While his fame initially rested on his achievements as a painter, he has also become known for his notebooks, in which he made drawings and notes on a variety of subjects, including anatomy, astronomy, botany, cartography, painting, and palaeontology.","Leonardo di ser Piero da Vinci (15 April 1452 – 2 May 1519) was an Italian polymath of the High Renaissance who was active as a painter, draughtsman, engineer, scientist, theorist, sculptor, and architect. While his fame initially rested on his achievements as a painter, he has also become known for his notebooks, in which he made drawings and notes on a variety of subjects, including anatomy, astronomy, botany, cartography, painting, and palaeontology. Leonardo is widely regarded as a genius who epitomised the Renaissance humanist ideal, and his collective works contributed to the development of European art to an extent rivalled only by that of his younger contemporary Michelangelo.
|
||||
Born out of wedlock to a successful notary and a lower-class woman in, or near, Vinci, he was educated in Florence by the Italian painter and sculptor Andrea del Verrocchio. He began his career in the city, but then spent much time in the service of Ludovico Sforza in Milan. Later, he worked in Florence and Milan again, as well as briefly in Rome, all while attracting a large following of imitators and students. Upon the invitation of Francis I, he spent his last three years in France, where he died in 1519. Since his death, there has not been a time when his achievements, diverse interests, personal life, and empirical thinking have failed to incite interest and admiration, making him a frequent namesake and subject in culture.
|
||||
Leonardo is identified as one of the greatest painters in the history of Western art and is often credited as the founder of the High Renaissance. Despite having many lost works and fewer than 25 attributed major works – including numerous unfinished works – he created some of the most influential paintings in the Western canon. The Mona Lisa is his best known work and is regarded as the world's most famous individual painting. The Last Supper is the most reproduced religious painting of all time and his Vitruvian Man drawing is also regarded as a cultural icon. In 2017, Salvator Mundi, attributed in whole or part to Leonardo, was sold at auction for US$450.3 million, setting a new record for the most expensive painting ever sold at public auction.
|
||||
Revered for his technological ingenuity, he conceptualised flying machines, a type of armoured fighting vehicle, concentrated solar power, a ratio machine that could be used in an adding machine, and the double hull. Relatively few of his designs were constructed or were even feasible during his lifetime, as the modern scientific approaches to metallurgy and engineering were only in their infancy during the Renaissance. Some of his smaller inventions, however, entered the world of manufacturing unheralded, such as an automated bobbin winder and a machine for testing the tensile strength of wire. He made substantial discoveries in anatomy, civil engineering, hydrodynamics, geology, optics, and tribology, but he did not publish his findings and they had little to no direct influence on subsequent science.","Leonardo da Vinci",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, High Renaissance, Unknown"", ""movement"": ""High Renaissance"", ""last_year"": 1512, ""birth_year"": 1452, ""death_year"": 1519, ""first_year"": 1480, ""pupils_raw"": ""Giovanni Antonio Boltraffio,"", ""birth_place"": ""Anchiano"", ""citizenship"": ""Republic of Florence"", ""death_place"": ""Amboise"", ""nationality"": ""Italian"", ""occupations"": ""scientist, astronomer, writer, composer, architect, engineer, physicist, mathematician, diplomat, inventor, polymath, zoologist, chemist, painter, sculptor, physiologist, botanist, caricaturist, philosopher, designer, anatomist, civil engineer, architectural draftsperson"", ""teachers_raw"": ""Andrea del Verrocchio,Andrea del Verrochio,"", ""painting_school"": null, ""styles_extended"": ""{Early Renaissance:44},{High Renaissance:141},{Unknown:20}"", ""art500k_movements"": ""{Italian Renaissance:1},{High Renaissance:204},{Renaissance:1}"", ""influenced_by_raw"": ""Piero della Francesca,Masaccio,Filippo Lippi,Hugo van der Goes,"", ""influenced_on_raw"": ""Bartolomeo Veneto,Mariotto Albertinelli,"", ""wikiart_pictures_count"": 205}","portraits/thumbs/Leonardo_da_Vinci_thumb.jpg"
|
||||
51,"Jacques-Louis David",1748,1825,10,"portraits/Jacques-Louis_David.jpg","Jacques-Louis David (French: [ʒaklwi david]; 30 August 1748 – 29 December 1825) was a French painter in the Neoclassical style, considered to be the preeminent painter of the era. In the 1780s, his cerebral brand of history painting marked a change in taste away from Rococo frivolity toward classical austerity, severity, and heightened feeling, which harmonized with the moral climate of the final years of the Ancien Régime.","Jacques-Louis David (French: [ʒaklwi david]; 30 August 1748 – 29 December 1825) was a French painter in the Neoclassical style, considered to be the preeminent painter of the era. In the 1780s, his cerebral brand of history painting marked a change in taste away from Rococo frivolity toward classical austerity, severity, and heightened feeling, which harmonized with the moral climate of the final years of the Ancien Régime.
|
||||
David later became an active supporter of the French Revolution and friend of Maximilien Robespierre (1758-1794), and was effectively a dictator of the arts under the French Republic. Imprisoned after Robespierre's fall from power, he aligned himself with yet another political regime upon his release: that of Napoleon, the First Consul of France. At this time he developed his Empire style, notable for its use of warm Venetian colours. After Napoleon's fall from Imperial power and the Bourbon revival, David exiled himself to Brussels, then in the United Kingdom of the Netherlands, where he remained until his death. David had many pupils, making him the strongest influence in French art of the early 19th century, especially academic Salon painting.
|
||||
David's career spans six different political regimes in France. He was a prolific artist and produced imagery that has entered the collective imagination. Famous paintings include The Death of Marat, Napoleon Crossing the Alps and The Coronation of Napoleon.","Jacques-Louis David",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Neoclassicism, Unknown"", ""movement"": ""Neoclassicism"", ""last_year"": 1824, ""birth_year"": 1748, ""death_year"": 1825, ""first_year"": 1772, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""City of Brussels"", ""nationality"": null, ""occupations"": ""politician, painter, architectural draftsperson"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Neoclassicism:78},{Unknown:34}"", ""art500k_movements"": ""{Neoclassicism:1}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 112}","portraits/thumbs/Jacques-Louis_David_thumb.jpg"
|
||||
59,"John Constable",1776,1837,11,"portraits/John_Constable.jpg","John Constable (; 11 June 1776 – 31 March 1837) was an English landscape painter in the Romantic tradition. Born in Suffolk, he is known principally for revolutionising the genre of landscape painting with his pictures of Dedham Vale, the area on the borderland of Suffolk and north Essex surrounding his home – now known as ""Constable Country"" – which he invested with an intensity of affection.","John Constable (; 11 June 1776 – 31 March 1837) was an English landscape painter in the Romantic tradition. Born in Suffolk, he is known principally for revolutionising the genre of landscape painting with his pictures of Dedham Vale, the area on the borderland of Suffolk and north Essex surrounding his home – now known as ""Constable Country"" – which he invested with an intensity of affection. ""I should paint my own places best"", he wrote to his friend John Fisher in 1821, ""painting is but another word for feeling.""
|
||||
Constable's most famous paintings include Wivenhoe Park (1816), Dedham Vale (1828) and The Hay Wain (1821). Although his paintings are now among the most popular and valuable in British art, he was never financially successful. He was elected to the Royal Academy of Arts at the age of 52. His work was embraced in France, where he sold more than in his native England and inspired the Barbizon school.","John Constable",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1837, ""birth_year"": 1776, ""death_year"": 1837, ""first_year"": 1800, ""pupils_raw"": null, ""birth_place"": ""East Bergholt"", ""citizenship"": ""Kingdom of Great Britain"", ""death_place"": ""Charlotte Street"", ""nationality"": ""British"", ""occupations"": ""painter, architectural draftsperson, landscape painter"", ""teachers_raw"": null, ""painting_school"": ""English school"", ""styles_extended"": ""{Romanticism:108},{Unknown:8}"", ""art500k_movements"": ""{Romanticism:117}"", ""influenced_by_raw"": ""Claude Lorrain,Thomas Gainsborough,Peter Paul Rubens,Annibale Carracci,Jacob van Ruisdael,"", ""influenced_on_raw"": ""Alfred Sisley,Jules Dupre,Claude Monet,Barbizon school,Impressionism,Théodore Géricault,Eugene Delacroix,Camille Corot,"", ""wikiart_pictures_count"": 116}","portraits/thumbs/John_Constable_thumb.jpg"
|
||||
60,"Juan Gris",1887,1927,19,"portraits/Juan_Gris.jpg","José Victoriano González-Pérez (23 March 1887 – 11 May 1927), better known as Juan Gris (Spanish: [ˈxwaŋ ˈɡɾis]; French: [gʀi]), was a Spanish painter born in Madrid who lived and worked in France for most of his active period. Closely connected to the innovative artistic genre Cubism, his works are among the movement's most distinctive.","José Victoriano González-Pérez (23 March 1887 – 11 May 1927), better known as Juan Gris (Spanish: [ˈxwaŋ ˈɡɾis]; French: [gʀi]), was a Spanish painter born in Madrid who lived and worked in France for most of his active period. Closely connected to the innovative artistic genre Cubism, his works are among the movement's most distinctive.","Juan Gris",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Analytical Cubism, Cubism, Synthetic Cubism"", ""movement"": ""Cubism"", ""last_year"": 1927, ""birth_year"": 1887, ""death_year"": 1927, ""first_year"": 1910, ""pupils_raw"": null, ""birth_place"": ""Madrid"", ""citizenship"": ""Spain"", ""death_place"": ""Boulogne-Billancourt"", ""nationality"": ""Spanish"", ""occupations"": ""illustrator, painter, sculptor, scenographer, visual artist, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": ""École de Paris"", ""styles_extended"": ""{Analytical Cubism:14},{Cubism:79},{Synthetic Cubism:112}"", ""art500k_movements"": ""{Cubism:200},{Modern art:2}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Karl Otto Gotz,Balcomb Greene,Fernando García Ponce,"", ""wikiart_pictures_count"": 205}","portraits/thumbs/Juan_Gris_thumb.jpg"
|
||||
58,"Joan Miró",1893,1983,24,"portraits/Joan_Mir_.jpg","Joan Miró i Ferrà ( mirr-OH, US also mee-ROH, Catalan: [ʒuˈam miˈɾo j fəˈra]; 20 April 1893 – 25 December 1983) was a Catalan painter, sculptor and ceramist from Spain. A museum dedicated to his work, the Fundació Joan Miró, was established in his native city of Barcelona in 1975, and another, the Fundació Pilar i Joan Miró, was established in his adoptive city of Palma, Mallorca in 1981.","Joan Miró i Ferrà ( mirr-OH, US also mee-ROH, Catalan: [ʒuˈam miˈɾo j fəˈra]; 20 April 1893 – 25 December 1983) was a Catalan painter, sculptor and ceramist from Spain. A museum dedicated to his work, the Fundació Joan Miró, was established in his native city of Barcelona in 1975, and another, the Fundació Pilar i Joan Miró, was established in his adoptive city of Palma, Mallorca in 1981.
|
||||
Earning international acclaim, his work has been interpreted as Surrealism but with a personal style, sometimes also veering into Fauvism and Expressionism. He was notable for his interest in the unconscious or the subconscious mind, reflected in his re-creation of the childlike. His difficult-to-classify works also had a manifestation of Catalan pride. In numerous interviews dating from the 1930s onwards, Miró expressed contempt for conventional painting methods as a way of supporting bourgeois society, and declared an ""assassination of painting"" in favour of upsetting the visual elements of established painting.","Joan Miró",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Expressionism, Automatic Painting, Cubism, Dada, Fauvism, Naïve Art (Primitivism), Surrealism, Unknown"", ""movement"": ""Surrealism"", ""last_year"": 1983, ""birth_year"": 1893, ""death_year"": 1983, ""first_year"": 1900, ""pupils_raw"": null, ""birth_place"": ""Barcelona"", ""citizenship"": ""Spain"", ""death_place"": ""Palma de Mallorca"", ""nationality"": ""Spanish"", ""occupations"": ""painter, sculptor, graphic artist, postage stamp designer, scenographer, designer, ceramicist, textile artist, lithographer, tapestry designer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Abstract Expressionism:6},{Automatic Painting:20},{Cubism:7},{Dada:1},{Fauvism:17},{Naïve Art (Primitivism):7},{Surrealism:142},{Unknown:2}"", ""art500k_movements"": ""{Surrealism:200}"", ""influenced_by_raw"": ""Marc Chagall,"", ""influenced_on_raw"": ""Morris Louis,Jackson Pollock,Salvador Dali,Ivan Eyre,"", ""wikiart_pictures_count"": 202}","portraits/thumbs/Joan_Mir__thumb.jpg"
|
||||
69,"Michelangelo",1475,1564,6,"portraits/Michelangelo.jpg","Michelangelo di Lodovico Buonarroti Simoni (6 March 1475 – 18 February 1564), known mononymously as Michelangelo, was an Italian sculptor, painter, architect, and poet of the High Renaissance. He was born in the Republic of Florence but was mostly active in Rome from his 30s onwards.","Michelangelo di Lodovico Buonarroti Simoni (6 March 1475 – 18 February 1564), known mononymously as Michelangelo, was an Italian sculptor, painter, architect, and poet of the High Renaissance. He was born in the Republic of Florence but was mostly active in Rome from his 30s onwards. His work was inspired by models from classical antiquity and had a lasting influence on Western art. Michelangelo's creative abilities and mastery in a range of artistic arenas define him as an archetypal Renaissance man, along with his rival and elder contemporary, Leonardo da Vinci. Given the sheer volume of surviving correspondence, sketches, and reminiscences, Michelangelo is one of the best-documented artists of the 16th century. He was lauded by contemporary biographers as the most accomplished artist of his era.
|
||||
Michelangelo achieved fame early. Two of his best-known works, the Pietà and David, were sculpted before the age of 30. Although he did not consider himself a painter, Michelangelo created two of the most influential frescoes in the history of Western art: the scenes from Genesis on the ceiling of the Sistine Chapel in Rome, and The Last Judgment on its altar wall. His design of the Laurentian Library pioneered Mannerist architecture. At the age of 71, he succeeded Antonio da Sangallo the Younger as the architect of St. Peter's Basilica. Michelangelo transformed the plan so that the Western end was finished to his design, as was the dome, with some modification, after his death.
|
||||
Michelangelo was the first Western artist whose biography was published while he was alive. Three biographies were published during his lifetime. One of them, by Giorgio Vasari, proposed that Michelangelo's work transcended that of any artist living or dead, and was ""supreme in not one art alone but in all three"".
|
||||
In his lifetime, Michelangelo was often called Il Divino (""the divine one""). His contemporaries admired his terribilità—his ability to instill a sense of awe in viewers of his art. Attempts by subsequent artists to imitate the expressive physicality of Michelangelo's style contributed to the rise of Mannerism, a short-lived movement in Western art between the High Renaissance and the Baroque.","Michelangelo",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""High Renaissance, Mannerism (Late Renaissance), Unknown"", ""movement"": ""High Renaissance"", ""last_year"": 1552, ""birth_year"": 1994, ""death_year"": 1564, ""first_year"": 1501, ""pupils_raw"": null, ""birth_place"": ""Cremona"", ""citizenship"": ""Republic of Florence"", ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""writer, architect, poet, engineer, general contractor, painter, sculptor"", ""teachers_raw"": ""Domenico Ghirlandaio,"", ""painting_school"": ""Florentine School"", ""styles_extended"": ""{High Renaissance:117},{Mannerism (Late Renaissance):54},{Unknown:11}"", ""art500k_movements"": ""{Italian Renaissance:3},{High Renaissance:178},{Renaissance:3}"", ""influenced_by_raw"": ""Giotto,Masaccio,"", ""influenced_on_raw"": ""Auguste Rodin,Henry Moore,Jose Clemente Orozco,Raphael,Daniele da Volterra,Maarten de Vos,Mannerism (Late Renaissance),Titian,Théodore Géricault,Edgar Degas,Jean-Francois Millet,John Singer Sargent,"", ""wikiart_pictures_count"": 182}","portraits/thumbs/Michelangelo_thumb.jpg"
|
||||
90,"Théodore Géricault",1791,1824,11,"portraits/Th_odore_G_ricault.jpg","Jean-Louis André Théodore Géricault (French: [ʒɑ̃lwi ɑ̃dʁe teɔdɔʁ ʒeʁiko]; 26 September 1791 – 26 January 1824) was a French painter and lithographer. His best-known painting is The Raft of the Medusa.","Jean-Louis André Théodore Géricault (French: [ʒɑ̃lwi ɑ̃dʁe teɔdɔʁ ʒeʁiko]; 26 September 1791 – 26 January 1824) was a French painter and lithographer. His best-known painting is The Raft of the Medusa. Despite his short life, he was one of the pioneers of the Romantic movement.","Théodore Géricault",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Orientalism, Realism, Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1824, ""birth_year"": 1791, ""death_year"": 1824, ""first_year"": 1808, ""pupils_raw"": null, ""birth_place"": ""Rouen"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""painter, sculptor, graphic artist, architectural draftsperson, lithographer"", ""teachers_raw"": ""Horace Vernet,Pierre-Narcisse Guerin,"", ""painting_school"": null, ""styles_extended"": ""{Orientalism:5},{Realism:16},{Romanticism:90},{Unknown:6}"", ""art500k_movements"": ""{Romanticism:114}"", ""influenced_by_raw"": ""John Constable,Peter Paul Rubens,Michelangelo,Henry Fuseli,John Singleton Copley,"", ""influenced_on_raw"": ""Eugene Delacroix,Piotr Michałowski,Romanticism,"", ""wikiart_pictures_count"": 117}","portraits/thumbs/Th_odore_G_ricault_thumb.jpg"
|
||||
73,"Pablo Picasso",1881,1973,19,"portraits/Pablo_Picasso.jpg","Pablo Ruiz Picasso (25 October 1881 – 8 April 1973) was a Spanish painter and sculptor who spent most of his adult life in France. One of the most influential artists of the 20th century, he is known for co-founding the Cubist movement, the invention of constructed sculpture, the co-invention of collage, and for the wide variety of styles that he helped develop and explore.","Pablo Ruiz Picasso (25 October 1881 – 8 April 1973) was a Spanish painter and sculptor who spent most of his adult life in France. One of the most influential artists of the 20th century, he is known for co-founding the Cubist movement, the invention of constructed sculpture, the co-invention of collage, and for the wide variety of styles that he helped develop and explore. Among his most famous works are the proto-Cubist Les Demoiselles d'Avignon (1907) and the anti-war painting Guernica (1937), a dramatic portrayal of the bombing of Guernica by German and Italian air forces during the Spanish Civil War. His career spanned more than 76 years, from his late teens to his death in 1973.
|
||||
Beginning his formal training under his father José Ruiz y Blasco aged seven, Picasso demonstrated extraordinary artistic talent from a young age, painting in a naturalistic manner through his childhood and adolescence. During the first decade of the 20th century, his style changed as he experimented with different theories, techniques, and ideas. After 1906, the Fauvist work of the older artist Henri Matisse motivated Picasso to explore more radical styles, beginning a fruitful rivalry between the two artists, who subsequently were often paired by critics as the leaders of modern art.
|
||||
Picasso's output, especially in his early career, is often periodized. While the names of many of his later periods are debated, the most commonly accepted periods in his work are the Blue Period (1901–1904), the Rose Period (1904–1906), the African-influenced Period (1907–1909), Analytic Cubism (1909–1912), and Synthetic Cubism (1912–1919), also referred to as the Crystal period. Much of Picasso's work of the late 1910s and early 1920s is in a neoclassical style, and his work in the mid-1920s often has characteristics of Surrealism. His later work often combines elements of his earlier styles.
|
||||
Exceptionally prolific throughout the course of his long life, Picasso achieved universal renown and immense fortune for his revolutionary artistic accomplishments, and became one of the best-known figures in 20th-century art.","Pablo Picasso",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Academicism, Analytical Cubism, Art Nouveau (Modern), Cubism, Expressionism, Impressionism, Japonism, Naïve Art (Primitivism), Neoclassicism, Pointillism, Post-Impressionism, Realism, Surrealism, Symbolism, Synthetic Cubism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1972, ""birth_year"": 1881, ""death_year"": 1973, ""first_year"": 1691, ""pupils_raw"": null, ""birth_place"": ""Málaga"", ""citizenship"": ""Spain"", ""death_place"": ""Mas Notre-Dame-de-Vie de Mougins"", ""nationality"": ""Spanish"", ""occupations"": ""screenwriter, photographer, graphic designer, illustrator, poster artist, painter, sculptor, costume designer, graphic artist, choreographer, jewelry designer, scenographer"", ""teachers_raw"": ""African Period,Neoclassicist & Surrealist Period,Early Years,"", ""painting_school"": ""École de Paris"", ""styles_extended"": ""{Academicism:1},{Analytical Cubism:48},{Art Nouveau (Modern):16},{Cubism:148},{Expressionism:190},{Impressionism:22},{Japonism:2},{Naïve Art (Primitivism):72},{Neoclassicism:74},{Pointillism:2},{Post-Impressionism:116},{Realism:26},{Surrealism:356},{Symbolism:31},{Synthetic Cubism:60},{Unknown:6}"", ""art500k_movements"": ""{Modern art:1},{Surrealism:1},{Cubism:1132}"", ""influenced_by_raw"": ""Marc Chagall,Henri Rousseau,El Greco,Francisco Goya,Paul Gauguin,Paul Cezanne,Henri Matisse,Auguste Rodin,Nicolas Poussin,Pierre-Auguste Renoir,"", ""influenced_on_raw"": ""Marc Chagall,Amedeo Modigliani,Willem de Kooning,Jasper Johns,Arshile Gorky,Lee Krasner,Jackson Pollock,Robert Delaunay,Piet Mondrian,Karel Appel,Balcomb Greene,Francis Bacon,David Alfaro Siqueiros,Salvador Dali,Tamara de Lempicka,Vilhelm Lundstrom,Cubism,Surrealism,Pop Art,Conceptual Art,"", ""wikiart_pictures_count"": 1170}","portraits/thumbs/Pablo_Picasso_thumb.jpg"
|
||||
68,"Max Ernst",1891,1976,24,"portraits/Max_Ernst.jpg","Max Ernst (; German: [ɛʁnst] 2 April 1891 – 1 April 1976) was a German-born painter, sculptor, printmaker, graphic artist, and poet. A prolific artist, Ernst was a primary pioneer of the Dada movement and surrealism in Europe.","Max Ernst (; German: [ɛʁnst] 2 April 1891 – 1 April 1976) was a German-born painter, sculptor, printmaker, graphic artist, and poet. A prolific artist, Ernst was a primary pioneer of the Dada movement and surrealism in Europe. He had no formal artistic training, but his experimental attitude toward the making of art resulted in his invention of frottage—a technique that uses pencil rubbings of textured objects and relief surfaces to create images—and grattage, an analogous technique in which paint is scraped across canvas to reveal the imprints of the objects placed beneath. Ernst is noted for his unconventional drawing methods as well as for creating novels and pamphlets using the method of collages. He served as a soldier for four years during World War I, which left him shocked, traumatised and critical of the modern world. During World War II he was designated an ""undesirable foreigner"" while living in France.
|
||||
Ernst was born in Brühl. He began painting in 1909 while studying at the University of Bonn, and later joined the Die Rheinischen Expressionisten group of artists. Ernst's work often featured ironic juxtapositions of grotesque elements with cubist and expressionist motifs. He had a fascination with birds, often including his alter ego, Loplop, a bird, in his work. He eventually settled in France and achieved financial success in the 1950s. He died in Paris on 1 April 1976.","Max Ernst",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Dada, Expressionism, Metaphysical art, Naïve Art (Primitivism), Surrealism"", ""movement"": ""Dada"", ""last_year"": 1970, ""birth_year"": 1891, ""death_year"": 1976, ""first_year"": 1928, ""pupils_raw"": null, ""birth_place"": ""Brühl"", ""citizenship"": ""United States of America"", ""death_place"": ""7th arrondissement of Paris"", ""nationality"": ""German"", ""occupations"": ""poet, psychiatrist, engraver, illustrator, painter, sculptor, graphic artist, jewelry designer, designer, printmaker, architectural draftsperson, drawer, lithographer, collagist"", ""teachers_raw"": null, ""painting_school"": ""Degenerate art"", ""styles_extended"": ""{Cubism:4},{Dada:24},{Expressionism:6},{Metaphysical art:7},{Naïve Art (Primitivism):5},{Surrealism:322}"", ""art500k_movements"": ""{Modern art:2},{Surrealism:1},{Dada,Surrealism:367}"", ""influenced_by_raw"": ""Arnold Böcklin,Salvador Dali,"", ""influenced_on_raw"": ""Karl Otto Gotz,Salvador Dali,"", ""wikiart_pictures_count"": 368}","portraits/thumbs/Max_Ernst_thumb.jpg"
|
||||
84,"René Magritte",1898,1967,24,"portraits/Ren__Magritte.jpg","René François Ghislain Magritte (French: [ʁəne fʁɑ̃swa ɡilɛ̃ maɡʁit]; 21 November 1898 – 15 August 1967) was a Belgian surrealist artist known for his depictions of familiar objects in unfamiliar, unexpected contexts, which often provoked questions about the nature and boundaries of reality and representation. His imagery has influenced pop art, minimalist art, and conceptual art.","René François Ghislain Magritte (French: [ʁəne fʁɑ̃swa ɡilɛ̃ maɡʁit]; 21 November 1898 – 15 August 1967) was a Belgian surrealist artist known for his depictions of familiar objects in unfamiliar, unexpected contexts, which often provoked questions about the nature and boundaries of reality and representation. His imagery has influenced pop art, minimalist art, and conceptual art.","René Magritte",1800,False,False,"{""gender"": null, ""source"": ""PainterPalette.csv"", ""styles"": ""Art Deco, Cubism, Impressionism, Post-Impressionism, Surrealism"", ""movement"": ""Surrealism"", ""last_year"": 1966, ""birth_year"": 1898, ""death_year"": null, ""first_year"": 1926, ""pupils_raw"": null, ""birth_place"": ""Lessines"", ""citizenship"": null, ""death_place"": null, ""nationality"": ""Belgian"", ""occupations"": null, ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Art Deco:14},{Cubism:12},{Impressionism:2},{Post-Impressionism:4},{Surrealism:340}"", ""art500k_movements"": ""{Surrealism:370}"", ""influenced_by_raw"": ""Giorgio de Chirico,"", ""influenced_on_raw"": ""Neo Rauch,L. S. Lowry,"", ""wikiart_pictures_count"": 372}","portraits/thumbs/Ren__Magritte_thumb.jpg"
|
||||
93,"Umberto Boccioni",1882,1916,20,"portraits/Umberto_Boccioni.jpg","Umberto Boccioni (US: ; Italian: [umˈbɛrto botˈtʃoːni]; 19 October 1882 – 17 August 1916) was an influential Italian painter and sculptor. He helped shape the revolutionary aesthetic of the Futurism movement as one of its principal figures.","Umberto Boccioni (US: ; Italian: [umˈbɛrto botˈtʃoːni]; 19 October 1882 – 17 August 1916) was an influential Italian painter and sculptor. He helped shape the revolutionary aesthetic of the Futurism movement as one of its principal figures. Despite his short life, his approach to the dynamism of form and the deconstruction of solid mass guided artists long after his death. His works are held by many public art museums, and in 1988 the Metropolitan Museum of Art in New York City organized a major retrospective of 100 pieces.","Umberto Boccioni",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Divisionism, Expressionism, Futurism, Impressionism, Pointillism, Post-Impressionism, Realism, Unknown"", ""movement"": ""Futurism"", ""last_year"": 1916, ""birth_year"": 1882, ""death_year"": 1916, ""first_year"": 1906, ""pupils_raw"": ""Paesaggio (montuoso),Sintesi plastica - Persona seduta,Nudo (complementarismo plastico di forma-colore),Dinamismo de un jugador de fútbol,L'antigrazioso,Figura,La Risata,Notturno,Il lutto,La Madre,Il sogno (Paolo e Francesca),Fabbrica Foltzer,Veneriamo la madre,Automobile e Caccia alla volpe,"", ""birth_place"": ""Reggio Calabria"", ""citizenship"": ""Kingdom of Italy"", ""death_place"": ""Verona"", ""nationality"": ""Italian"", ""occupations"": ""photographer, painter, sculptor, graphic artist, art critic, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": ""Artists2/Umberto Boccioni/Landscape Mountains 1916.jpg,Artists2/Umberto Boccioni/Nocturne 1911.jpg,Artists2/Umberto Boccioni/Factory Foltzer 1909.jpg,"", ""painting_school"": ""Milan Futurist Group,Lacerba"", ""styles_extended"": ""{Cubism:11},{Divisionism:15},{Expressionism:9},{Futurism:40},{Impressionism:8},{Pointillism:3},{Post-Impressionism:6},{Realism:3},{Unknown:3}"", ""art500k_movements"": ""{Modern art:1},{Futurism:101}"", ""influenced_by_raw"": ""David Alfaro Siqueiros,"", ""influenced_on_raw"": ""David Alfaro Siqueiros,forests-and-trees,female-nude,allegories-and-symbols,female-portraits,streets-and-squares,handwork,allegories-and-symbols,animals,allegories-and-symbols,horses,games-and-sport,allegories-and-symbols,gates-and-towers,dishware-and-cutlery,reading-and-writing,allegories-and-symbols,streets-and-squares,houses-and-buildings,couples,female-portraits,allegories-and-symbols,characters-and-emotions,celebrations-and-festivals,allegories-and-symbols,female-portraits,characters-and-emotions,characters-and-emotions,night,fields-and-plains,factories-and-plants,troubles-and-disasters,couples,fictional-characters,Paolo,Francesca,animals,horses,factories-and-plants,countryside,gardens-and-parks,male-portraits,famous-people,animals,dogs,roads-and-vehicles,foxes,rivers-and-waterfalls,walking,lonely figures,"", ""wikiart_pictures_count"": 98}","portraits/thumbs/Umberto_Boccioni_thumb.jpg"
|
||||
97,"Willem de Kooning",1904,1997,25,"portraits/Willem_de_Kooning.jpg","Willem de Kooning ( də KOO-ning, Dutch: [ˈʋɪləm də ˈkoːnɪŋ]; April 24, 1904 – March 19, 1997) was a Dutch-American abstract expressionist artist. S.","Willem de Kooning ( də KOO-ning, Dutch: [ˈʋɪləm də ˈkoːnɪŋ]; April 24, 1904 – March 19, 1997) was a Dutch-American abstract expressionist artist. Born in Rotterdam, in the Netherlands, he moved to the United States in 1926, becoming a U.S. citizen in 1962. In 1943, he married painter Elaine Fried.
|
||||
In the years after World War II, De Kooning painted in a style that came to be referred to as abstract expressionism or ""action painting"", and was part of a group of artists that came to be known as the New York School. Other painters in this group included Jackson Pollock, Elaine de Kooning, Lee Krasner, Franz Kline, Arshile Gorky, Mark Rothko, Hans Hofmann, John Ferren, Nell Blaine, Conrad Marca-Relli, James Brooks, Adolph Gottlieb, Jack Tworkov, Norman Lewis, Anne Ryan, Robert Motherwell, Philip Guston, Clyfford Still, and Richard Pousette-Dart. De Kooning's retrospective held at MoMA in 2011–2012 rendered him one of the best-known artists of the 20th century.","Willem de Kooning",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Modern art:1},{Contemporary art:1},{Abstract Expressionism:83}"", ""last_year"": 1987, ""birth_year"": 1904, ""death_year"": 1997, ""first_year"": 1939, ""pupils_raw"": null, ""birth_place"": ""Rotterdam"", ""citizenship"": ""United States of America"", ""death_place"": ""East Hampton"", ""nationality"": null, ""occupations"": ""['painter', 'sculptor', 'university teacher', 'designer', 'printmaker', 'drawer']"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": null}","portraits/thumbs/Willem_de_Kooning_thumb.jpg"
|
||||
5,"Andrea del Castagno",1421,1457,4,"portraits/Andrea_del_Castagno.jpg","Andrea del Castagno (Italian: [anˈdrɛːa del kaˈstaɲɲo]; c. 1419 – 19 August 1457), also known patronymically as Andrea di Bartolo di Bargilla (pronounced [anˈdrɛːa di ˈbartolo di barˈdʒilla]), was an Italian Renaissance painter in Florence, influenced chiefly by Masaccio and Giotto di Bondone.","Andrea del Castagno (Italian: [anˈdrɛːa del kaˈstaɲɲo]; c. 1419 – 19 August 1457), also known patronymically as Andrea di Bartolo di Bargilla (pronounced [anˈdrɛːa di ˈbartolo di barˈdʒilla]), was an Italian Renaissance painter in Florence, influenced chiefly by Masaccio and Giotto di Bondone. His works include frescoes in Sant'Apollonia in Florence and the painted equestrian monument of Niccolò da Tolentino (1456) in Florence Cathedral. He in turn influenced the Ferrarese school of Cosmè Tura, Francesco del Cossa and Ercole de' Roberti.","Andrea del Castagno",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, Unknown"", ""movement"": ""Early Renaissance"", ""last_year"": 1450, ""birth_year"": 1420, ""death_year"": 1423, ""first_year"": 1450, ""pupils_raw"": null, ""birth_place"": ""San Godenzo"", ""citizenship"": null, ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""painter"", ""teachers_raw"": null, ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Early Renaissance:39},{Unknown:1}"", ""art500k_movements"": ""{Early Renaissance:40}"", ""influenced_by_raw"": ""Christianity,Jesus-Christ,Christianity,saints-and-apostles,God-the-Father,Virgin-Mary,"", ""influenced_on_raw"": ""Basilica di San Marco, Venice, Italy,"", ""wikiart_pictures_count"": 40}","portraits/thumbs/Andrea_del_Castagno_thumb.jpg"
|
||||
98,"Zeuxis",-500,-400,1,"portraits/Zeuxis.jpg","Zeuxis (; Ancient Greek: Ζεῦξις) (of Heraclea) was a late 5th-century- early 4th-century BCE Greek artist famed for his ability to create images that appeared highly realistic. None of his works survive, but anecdotes about Zeuxis's art and life have been referenced often in the history and literature of art and in art theory.","Zeuxis (; Ancient Greek: Ζεῦξις) (of Heraclea) was a late 5th-century- early 4th-century BCE Greek artist famed for his ability to create images that appeared highly realistic. None of his works survive, but anecdotes about Zeuxis's art and life have been referenced often in the history and literature of art and in art theory.
|
||||
Much of the information about Zeuxis comes from Pliny the Elder's Natural History, but his work is also discussed by Xenophon and Aristotle. One of the most famous stories about Zeuxis centers on an artistic competition with the artist Parrhasius to prove which artist could create a greater illusion of nature. Zeuxis, Timanthes, and Parrhasius were painters of the Ionian School of painting. The Ionian School flourished during the 4th century BCE.","Zeuxis (painter)",-500,False,False,NULL,"portraits/thumbs/Zeuxis_thumb.jpg"
|
||||
70,"Nicolas Poussin",1594,1665,8,"portraits/Nicolas_Poussin.jpg","Nicolas Poussin (UK: , US: , French: [nikɔla pusɛ̃]; June 1594 – 19 November 1665) was a leading painter of the classical French Baroque style, although he spent most of his working life in Rome. Most of his works were on religious and mythological subjects painted for a small group of Italian and French collectors.","Nicolas Poussin (UK: , US: , French: [nikɔla pusɛ̃]; June 1594 – 19 November 1665) was a leading painter of the classical French Baroque style, although he spent most of his working life in Rome. Most of his works were on religious and mythological subjects painted for a small group of Italian and French collectors. He returned to Paris for a brief period to serve as First Painter to the King under Louis XIII and Cardinal Richelieu, but soon returned to Rome and resumed his more traditional themes. In his later years he gave growing prominence to the landscape in his paintings. His work is characterized by clarity, logic, and order, and favors line over color. Until the 20th century he remained a major inspiration for such classically-oriented artists as Jacques-Louis David, Jean-Auguste-Dominique Ingres and Paul Cézanne.
|
||||
Details of Poussin's artistic training are somewhat obscure. Around 1612 he traveled to Paris, where he studied under minor masters and completed his earliest surviving works. His enthusiasm for the Italian works he saw in the royal collections in Paris motivated him to travel to Rome in 1624, where he studied the works of Renaissance and Baroque painters—especially Raphael, who had a powerful influence on his style. He befriended a number of artists who shared his classicizing tendencies, and met important patrons, such as Cardinal Francesco Barberini and the antiquarian Cassiano dal Pozzo. The commissions Poussin received for modestly scaled paintings of religious, mythological, and historical subjects allowed him to develop his individual style in works such as The Death of Germanicus, The Massacre of the Innocents, and the first of his two series of the Seven Sacraments.
|
||||
He was persuaded to return to France in 1640 to be First Painter to the King but, dissatisfied with the overwhelming workload and the court intrigues, returned permanently to Rome after a little more than a year. Among the important works from his later years are Orion Blinded Searching for the Sun, Landscape with Hercules and Cacus, and The Seasons.","Nicolas Poussin",1600,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Classicism, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1665, ""birth_year"": 1594, ""death_year"": 1665, ""first_year"": 1624, ""pupils_raw"": null, ""birth_place"": ""Les Andelys"", ""citizenship"": ""France"", ""death_place"": ""Rome"", ""nationality"": ""French"", ""occupations"": ""painter, graphic artist, architectural draftsperson"", ""teachers_raw"": ""Georges Lallemand,"", ""painting_school"": null, ""styles_extended"": ""{Baroque:51},{Classicism:112},{Unknown:10}"", ""art500k_movements"": ""{Baroque:118}"", ""influenced_by_raw"": ""Raphael,Pietro da Cortona,Titian,Guido Reni,"", ""influenced_on_raw"": ""Jacques-Louis David,Jean Auguste Dominique Ingres,Paul Cezanne,Sebastien Bourdon,Georges Seurat,Pablo Picasso,Georges Braque,Jean Hugo,Jean-Francois Millet,Camille Corot,Rosa Bonheur,Joseph Anton Koch,"", ""wikiart_pictures_count"": 173}","portraits/thumbs/Nicolas_Poussin_thumb.jpg"
|
||||
13,"Bronzino",1503,1572,7,"portraits/Bronzino.jpg","Agnolo di Cosimo (Italian: [ˈaɲɲolo di ˈkɔːzimo]; 17 November 1503 – 23 November 1572), usually known as Bronzino (Italian: Il Bronzino [il bronˈdziːno]) or Agnolo Bronzino, was an Italian Mannerist painter from Florence. His sobriquet, Bronzino, may refer to his relatively dark skin or reddish hair.","Agnolo di Cosimo (Italian: [ˈaɲɲolo di ˈkɔːzimo]; 17 November 1503 – 23 November 1572), usually known as Bronzino (Italian: Il Bronzino [il bronˈdziːno]) or Agnolo Bronzino, was an Italian Mannerist painter from Florence. His sobriquet, Bronzino, may refer to his relatively dark skin or reddish hair.
|
||||
He lived all his life in Florence, and from his late 30s was kept busy as the court painter of Cosimo I de' Medici, Grand Duke of Tuscany. He was mainly a portraitist, but also painted many religious subjects, and a few allegorical subjects, which include what is probably his best-known work, Venus, Cupid, Folly and Time, c. 1544–45, now in London. Many portraits of the Medicis exist in several versions with varying degrees of participation by Bronzino himself, as Cosimo was a pioneer of the copied portrait sent as a diplomatic gift.
|
||||
He trained with Pontormo, the leading Florentine painter of the first generation of Mannerism, and his style was greatly influenced by him, but his elegant and somewhat elongated figures always appear calm and somewhat reserved, lacking the agitation and emotion of those by his teacher. They have often been found cold and artificial, and his reputation suffered from the general critical disfavour attached to Mannerism in the 19th and early 20th centuries. Recent decades have been more appreciative of his art.","Bronzino",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Mannerism (Late Renaissance):84}"", ""last_year"": 1569, ""birth_year"": 1503, ""death_year"": 1572, ""first_year"": 1505, ""pupils_raw"": null, ""birth_place"": ""Florence"", ""citizenship"": ""Italy"", ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""['poet', 'painter', 'architectural draftsperson']"", ""teachers_raw"": null, ""painting_school"": ""Florentine School"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": null}","portraits/thumbs/Bronzino_thumb.jpg"
|
||||
30,"Francis Picabia",1879,1953,23,"portraits/Francis_Picabia.jpg","Francis Picabia (French: [fʁɑ̃sis pikabja]: born Francis-Marie Martinez de Picabia; 22 January 1879 – 30 November 1953) was a French avant-garde painter, writer, filmmaker, magazine publisher, poet, and typographist closely associated with Dada.
|
||||
When considering the many styles that Picabia painted in, observers have described his career as ""shape-shifting"" or ""kaleidoscopic"".","Francis Picabia (French: [fʁɑ̃sis pikabja]: born Francis-Marie Martinez de Picabia; 22 January 1879 – 30 November 1953) was a French avant-garde painter, writer, filmmaker, magazine publisher, poet, and typographist closely associated with Dada.
|
||||
When considering the many styles that Picabia painted in, observers have described his career as ""shape-shifting"" or ""kaleidoscopic"". After experimenting with Impressionism and Pointillism, Picabia became associated with Cubism. His highly abstract planar compositions were colourful and rich in contrasts. He was one of the early major figures of the Dada movement in the United States and in France before denouncing it in 1921. He was later briefly associated with Surrealism, but would soon turn his back on the art establishment.","Francis Picabia",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Cubism, Dada, Divisionism, Expressionism, Fauvism, Impressionism, Kitsch, Orphism, Surrealism, Unknown"", ""movement"": ""Abstract Art"", ""last_year"": 1951, ""birth_year"": 1879, ""death_year"": 1953, ""first_year"": 1898, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""screenwriter, writer, poet, painter, sculptor, drawer"", ""teachers_raw"": null, ""painting_school"": ""École de Paris,Section d'Or (Puteaux Group)"", ""styles_extended"": ""{Abstract Art:12},{Cubism:6},{Dada:14},{Divisionism:3},{Expressionism:18},{Fauvism:1},{Impressionism:22},{Kitsch:19},{Orphism:1},{Surrealism:34},{Unknown:1}"", ""art500k_movements"": ""{Cubism:1},{Dada:1},{Modern art:1},{Dada,Surrealism:131}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 131}","portraits/thumbs/Francis_Picabia_thumb.jpg"
|
||||
3,"Alphonse Mucha",1860,1939,16,"portraits/Alphonse_Mucha.jpg","Alfons Maria Mucha (Czech: [ˈalfons ˈmuxa] ; 24 July 1860 – 14 July 1939), known in English as Alphonse Mucha, was a Czech painter, illustrator, and graphic artist. Living in Paris during the Art Nouveau period, he was widely known for his distinctly stylised and decorative theatrical posters, particularly those of Sarah Bernhardt.","Alfons Maria Mucha (Czech: [ˈalfons ˈmuxa] ; 24 July 1860 – 14 July 1939), known in English as Alphonse Mucha, was a Czech painter, illustrator, and graphic artist. Living in Paris during the Art Nouveau period, he was widely known for his distinctly stylised and decorative theatrical posters, particularly those of Sarah Bernhardt. He produced illustrations, advertisements, decorative panels, as well as designs, which became among the best-known images of the period.
|
||||
In the second part of his career, at the age of 57, he returned to his homeland and devoted himself to a series of twenty monumental symbolist canvases known as The Slav Epic, depicting the history of all the Slavic peoples of the world, which he painted between 1912 and 1926. In 1928, on the 10th anniversary of the independence of Czechoslovakia, he presented the series to the Czech nation. He considered it his most important work.","Alphonse Mucha",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern), Romanticism, Symbolism"", ""movement"": ""Art Nouveau"", ""last_year"": 1938, ""birth_year"": 1860, ""death_year"": 1939, ""first_year"": 1894, ""pupils_raw"": null, ""birth_place"": ""Ivančice"", ""citizenship"": ""Nazi Germany"", ""death_place"": ""Prague"", ""nationality"": ""Czech"", ""occupations"": ""photographer, teacher, graphic designer, illustrator, poster artist, painter, university teacher, graphic artist, postage stamp designer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Art Nouveau (Modern):141},{Romanticism:18},{Symbolism:12}"", ""art500k_movements"": ""{Art Nouveau:151}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""J. C. Leyendecker,"", ""wikiart_pictures_count"": 171}","portraits/thumbs/Alphonse_Mucha_thumb.jpg"
|
||||
34,"Georges Seurat",1859,1891,14,"portraits/Georges_Seurat.jpg","Georges Pierre Seurat (UK: SUR-ah, -ə, US: suu-RAH; French: [ʒɔʁʒ pjɛʁ sœʁa]; 2 December 1859 – 29 March 1891) was a French post-Impressionist artist. He devised the painting techniques known as chromoluminarism and pointillism and used conté crayon for drawings on paper with a rough surface.","Georges Pierre Seurat (UK: SUR-ah, -ə, US: suu-RAH; French: [ʒɔʁʒ pjɛʁ sœʁa]; 2 December 1859 – 29 March 1891) was a French post-Impressionist artist. He devised the painting techniques known as chromoluminarism and pointillism and used conté crayon for drawings on paper with a rough surface.
|
||||
Seurat's artistic personality combined qualities that are usually thought of as opposed and incompatible: on the one hand, his extreme and delicate sensibility, on the other, a passion for logical abstraction and an almost mathematical precision of mind. His large-scale work A Sunday Afternoon on the Island of La Grande Jatte (1884–1886) altered the direction of modern art by initiating Neo-Impressionism, and is one of the icons of late 19th-century painting.","Georges Seurat",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Neo-Impressionism, Neoclassicism, Pointillism, Post-Impressionism, Unknown"", ""movement"": ""Neo-Impressionism"", ""last_year"": 1890, ""birth_year"": 1859, ""death_year"": 1891, ""first_year"": 1877, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""painter, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": ""Société des Artistes Indépendants (Society of Independent Artists)"", ""styles_extended"": ""{Impressionism:29},{Neo-Impressionism:92},{Neoclassicism:2},{Pointillism:1},{Post-Impressionism:42},{Unknown:7}"", ""art500k_movements"": ""{Neo-impressionism:2},{Post-Impressionism:176},{Modern art:3}"", ""influenced_by_raw"": ""Nicolas Poussin,Jean-Francois Millet,"", ""influenced_on_raw"": ""Charles Angrand,Edvard Munch,Childe Hassam,Robert Delaunay,"", ""wikiart_pictures_count"": 173}","portraits/thumbs/Georges_Seurat_thumb.jpg"
|
||||
44,"Henri Matisse",1869,1954,17,"portraits/Henri_Matisse.jpg","Henri Émile Benoît Matisse (French: [ɑ̃ʁi emil bənwa matis]; 31 December 1869 – 3 November 1954) was a French visual artist, known for both his use of colour and his fluid and original draughtsmanship. He was a draughtsman, printmaker, and sculptor, but is known primarily as a painter.","Henri Émile Benoît Matisse (French: [ɑ̃ʁi emil bənwa matis]; 31 December 1869 – 3 November 1954) was a French visual artist, known for both his use of colour and his fluid and original draughtsmanship. He was a draughtsman, printmaker, and sculptor, but is known primarily as a painter.
|
||||
Matisse is commonly regarded, along with Pablo Picasso, as one of the artists who best helped to define the revolutionary developments in the visual arts throughout the opening decades of the twentieth century, responsible for significant developments in painting and sculpture.
|
||||
The intense colourism of the works he painted between 1900 and 1905 brought him notoriety as one of the Fauves (French for ""wild beasts""). Many of his finest works were created in the decade or so after 1906, when he developed a rigorous style that emphasized flattened forms and decorative pattern. In 1917, he relocated to a suburb of Nice on the French Riviera, and the more relaxed style of his work during the 1920s gained him critical acclaim as an upholder of the classical tradition in French painting. After 1930, he adopted a bolder simplification of form. When ill health in his final years prevented him from painting, he created an important body of work in the medium of cut paper collage.
|
||||
His mastery of the expressive language of colour and drawing, displayed in a body of work spanning over a half-century, won him recognition as a leading figure in modern art.","Henri Matisse",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Abstract Expressionism, Color Field Painting, Contemporary Realism, Cubism, Divisionism, Expressionism, Fauvism, Figurative Expressionism, Impressionism, Neo-Impressionism, Orientalism, Post-Impressionism, Realism, Unknown"", ""movement"": ""Impressionism"", ""last_year"": 1953, ""birth_year"": 1869, ""death_year"": 1954, ""first_year"": 1890, ""pupils_raw"": null, ""birth_place"": ""Le Cateau-Cambrésis"", ""citizenship"": ""France"", ""death_place"": ""Nice"", ""nationality"": ""French"", ""occupations"": ""painter, sculptor, ceramicist, printmaker, drawer, lithographer"", ""teachers_raw"": null, ""painting_school"": ""École de Paris,Les Fauves"", ""styles_extended"": ""{Abstract Art:3},{Abstract Expressionism:44},{Color Field Painting:2},{Contemporary Realism:5},{Cubism:9},{Divisionism:1},{Expressionism:278},{Fauvism:258},{Figurative Expressionism:1},{Impressionism:67},{Neo-Impressionism:11},{Orientalism:13},{Post-Impressionism:152},{Realism:29},{Unknown:135}"", ""art500k_movements"": ""{Impressionism,Post-Impressionism:977}"", ""influenced_by_raw"": ""Edvard Munch,Pierre-Auguste Renoir,Alfred Sisley,"", ""influenced_on_raw"": ""Marc Chagall,Pablo Picasso,Lee Krasner,Max Pechstein,Fernand Leger,Chaim Soutine,Karel Appel,Balcomb Greene,Man Ray,Mikhail Larionov,Guan Zilan,Carlos Nadal,"", ""wikiart_pictures_count"": 1008}","portraits/thumbs/Henri_Matisse_thumb.jpg"
|
||||
54,"Jean-Auguste-Dominique Ingres",1780,1867,10,"portraits/Jean-Auguste-Dominique_Ingres.jpg","Jean-Auguste-Dominique Ingres ( ANG-grə; French: [ʒɑ̃ oɡyst dɔminik ɛ̃ɡʁ]; 29 August 1780 – 14 January 1867) was a French Neoclassical painter. Ingres was profoundly influenced by past artistic traditions and aspired to become the guardian of academic orthodoxy against the ascendant Romantic style.","Jean-Auguste-Dominique Ingres ( ANG-grə; French: [ʒɑ̃ oɡyst dɔminik ɛ̃ɡʁ]; 29 August 1780 – 14 January 1867) was a French Neoclassical painter. Ingres was profoundly influenced by past artistic traditions and aspired to become the guardian of academic orthodoxy against the ascendant Romantic style. Although he considered himself a painter of history in the tradition of Nicolas Poussin and Jacques-Louis David, it is his portraits, both painted and drawn, that are recognized as his greatest legacy. His expressive distortions of form and space made him an important precursor of modern art, influencing Henri Matisse, Pablo Picasso, and other modernists.
|
||||
Born into a modest family in Montauban, he travelled to Paris to study in the studio of David. In 1802 he made his Salon debut, and won the Prix de Rome for his painting The Ambassadors of Agamemnon in the tent of Achilles. By the time he departed in 1806 for his residency in Rome, his style—revealing his close study of Italian and Flemish Renaissance masters—was fully developed, and would change little for the rest of his life. While working in Rome and subsequently Florence from 1806 to 1824, he regularly sent paintings to the Paris Salon, where they were faulted by critics who found his style bizarre and archaic. He received few commissions during this period for the history paintings he aspired to paint, but was able to support himself and his wife as a portrait painter and draughtsman.
|
||||
He was finally recognized at the Salon in 1824, when his Raphaelesque painting, The Vow of Louis XIII, was met with acclaim, and Ingres was acknowledged as the leader of the Neoclassical school in France. Although the income from commissions for history paintings allowed him to paint fewer portraits, his Portrait of Monsieur Bertin marked his next popular success in 1833. The following year, his indignation at the harsh criticism of his ambitious composition The Martyrdom of Saint Symphorian caused him to return to Italy, where he assumed directorship of the French Academy in Rome in 1835. He returned to Paris for good in 1841. In his later years he painted new versions of many of his earlier compositions, a series of designs for stained glass windows, several important portraits of women, and The Turkish Bath, the last of his several Orientalist paintings of the female nude, which he finished at the age of 83.","Jean-Auguste-Dominique Ingres",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Neoclassicism, Orientalism, Romanticism, Unknown"", ""movement"": ""Neoclassicism"", ""last_year"": 1867, ""birth_year"": 1780, ""death_year"": 1867, ""first_year"": 1797, ""pupils_raw"": null, ""birth_place"": ""Montauban"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""drawer"", ""teachers_raw"": ""Jacques-Louis David,Académie des Beaux-Arts, Paris, France,École des Beaux-Arts, Paris, France,"", ""painting_school"": null, ""styles_extended"": ""{Neoclassicism:270},{Orientalism:3},{Romanticism:2},{Unknown:31}"", ""art500k_movements"": ""{Neoclassicism:286}"", ""influenced_by_raw"": ""Nicolas Poussin,"", ""influenced_on_raw"": ""Tamara de Lempicka,Mark Ryden,Paul Gauguin,Edgar Degas,Pierre-Auguste Renoir,"", ""wikiart_pictures_count"": 306}","portraits/thumbs/Jean-Auguste-Dominique_Ingres_thumb.jpg"
|
||||
83,"Rembrandt",1606,1669,8,"portraits/Rembrandt.jpg","Rembrandt Harmenszoon van Rijn (15 July 1606 – 4 October 1669), known mononymously as Rembrandt, was a Dutch Golden Age painter, printmaker, and draughtsman. He is generally considered one of the greatest visual artists in the history of Western art.","Rembrandt Harmenszoon van Rijn (15 July 1606 – 4 October 1669), known mononymously as Rembrandt, was a Dutch Golden Age painter, printmaker, and draughtsman. He is generally considered one of the greatest visual artists in the history of Western art. It is estimated that Rembrandt's surviving works amount to about three hundred paintings, three hundred etchings, and several hundred drawings.
|
||||
Unlike most Dutch painters of the 17th century, Rembrandt's works depict a wide range of styles and subject matter, from portraits and self-portraits to landscapes, genre scenes, allegorical and historical scenes, biblical and mythological subjects and animal studies. His contributions to art came in a period that historians call the Dutch Golden Age.
|
||||
Rembrandt never went abroad, but was considerably influenced by the work of the Italian Old Masters and Dutch and Flemish artists who had studied in Italy. After he achieved youthful success as a portrait painter, Rembrandt's later years were marked by personal tragedy and financial hardships. Yet his etchings and paintings were popular throughout his lifetime, his reputation as an artist remained high, and for twenty years he taught many important Dutch painters. Rembrandt's portraits of his contemporaries, self-portraits and illustrations of scenes from the Bible are regarded as his greatest creative triumphs. His approximately 100 self-portraits, over 40 of which are paintings, form an intimate autobiography.","Rembrandt",1600,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Tenebrism, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1700, ""birth_year"": 1606, ""death_year"": 1669, ""first_year"": 1600, ""pupils_raw"": ""Carel Fabritius,Nicolaes Maes,Gerrit Dou,Samuel Dirksz van Hoogstraten,Ferdinand Bol,"", ""birth_place"": ""Leiden"", ""citizenship"": ""Dutch Republic"", ""death_place"": ""Amsterdam"", ""nationality"": ""Dutch"", ""occupations"": ""painter, collector, art collector, etcher, printmaker, drawer"", ""teachers_raw"": null, ""painting_school"": ""Dutch School"", ""styles_extended"": ""{Baroque:587},{Tenebrism:128},{Unknown:52}"", ""art500k_movements"": ""{Dutch Golden Age:15},{Baroque:781}"", ""influenced_by_raw"": ""Peter Paul Rubens,Caravaggio,Titian,Caravaggisti,"", ""influenced_on_raw"": ""Carel Fabritius,Giovanni Battista Tiepolo,Gustave Courbet,Chaim Soutine,Francis Bacon,Ilya Repin,Andrea Mantegna,Peredvizhniki (Society for Traveling Art Exhibitions),Ferdinand Bol,Piotr Michałowski,Odd Nerdrum,"", ""wikiart_pictures_count"": 767}","portraits/thumbs/Rembrandt_thumb.jpg"
|
||||
88,"Sandro Botticelli",1445,1510,4,"portraits/Sandro_Botticelli.jpg","Alessandro di Mariano di Vanni Filipepi (c. 1445 – May 17, 1510), better known as Sandro Botticelli ( BOT-ih-CHEL-ee; Italian: [ˈsandro bottiˈtʃɛlli]) or simply Botticelli, was an Italian painter of the Early Renaissance.","Alessandro di Mariano di Vanni Filipepi (c. 1445 – May 17, 1510), better known as Sandro Botticelli ( BOT-ih-CHEL-ee; Italian: [ˈsandro bottiˈtʃɛlli]) or simply Botticelli, was an Italian painter of the Early Renaissance. Botticelli's posthumous reputation suffered until the late 19th century, when he was rediscovered by the Pre-Raphaelites who stimulated a reappraisal of his work. Since then, his paintings have been seen to represent the linear grace of late Italian Gothic and some Early Renaissance painting, even though they date from the latter half of the Italian Renaissance period.
|
||||
In addition to the mythological subjects for which he is best known today, Botticelli painted a wide range of religious subjects (including dozens of renditions of the Madonna and Child, many in the round tondo shape) and also some portraits. His best-known works are The Birth of Venus and Primavera, both in the Uffizi in Florence, which holds many of Botticelli's works. Botticelli lived all his life in the same neighbourhood of Florence; his only significant times elsewhere were the months he spent painting in Pisa in 1474 and the Sistine Chapel in Rome in 1481–82.
|
||||
Only one of Botticelli's paintings, the Mystic Nativity (National Gallery, London) is inscribed with a date (1501), but others can be dated with varying degrees of certainty on the basis of archival records, so the development of his style can be traced with some confidence. He was an independent master for all the 1470s, which saw his reputation soar. The 1480s were his most successful decade, the one in which his large mythological paintings were completed along with many of his most famous Madonnas. By the 1490s, his style became more personal and to some extent mannered. His last works show him moving in a direction opposite to that of Leonardo da Vinci (seven years his junior) and the new generation of painters creating the High Renaissance style, and instead returning to a style that many have described as more Gothic or ""archaic"".","Sandro Botticelli",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, Renaissance, Unknown"", ""movement"": ""Early Renaissance"", ""last_year"": 1510, ""birth_year"": 1445, ""death_year"": 1510, ""first_year"": 1465, ""pupils_raw"": null, ""birth_place"": ""Florence"", ""citizenship"": null, ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""painter, architectural draftsperson, drawer, fresco painter"", ""teachers_raw"": ""Filippo Lippi,"", ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Early Renaissance:97},{Renaissance:8},{Unknown:32}"", ""art500k_movements"": ""{Early Renaissance:135}"", ""influenced_by_raw"": ""Masaccio,"", ""influenced_on_raw"": ""Walter Crane,"", ""wikiart_pictures_count"": 137}","portraits/thumbs/Sandro_Botticelli_thumb.jpg"
|
||||
4,"André Derain",1880,1954,17,"portraits/Andr__Derain.jpg","André Derain (; French: [ɑ̃dʁe dəʁɛ̃]; 17 June 1880 – 8 September 1954) was a French artist, painter, sculptor and co-founder, with Henri Matisse, of Fauvism. His paintings of 1905–1906 are characterized by riotous colourism in the Fauve style.","André Derain (; French: [ɑ̃dʁe dəʁɛ̃]; 17 June 1880 – 8 September 1954) was a French artist, painter, sculptor and co-founder, with Henri Matisse, of Fauvism. His paintings of 1905–1906 are characterized by riotous colourism in the Fauve style. By 1910, however, his work had become more austere as a result of his study of Cézanne and the old masters. After the First World War, Derain became one of the leaders of the new classicism in the arts known as the Return to order.","André Derain",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Divisionism, Expressionism, Fauvism, Naïve Art (Primitivism), Neo-Impressionism, Neoclassicism, Post-Impressionism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1953, ""birth_year"": 1880, ""death_year"": 1954, ""first_year"": 1899, ""pupils_raw"": null, ""birth_place"": ""Chatou"", ""citizenship"": ""France"", ""death_place"": ""Garches"", ""nationality"": ""French"", ""occupations"": ""architectural draftsperson, drawer, photographer, engraver, illustrator, painter, sculptor, costume designer, graphic artist, choreographer, jewelry designer, scenographer, designer, printmaker"", ""teachers_raw"": null, ""painting_school"": ""Les Fauves"", ""styles_extended"": ""{Cubism:13},{Divisionism:2},{Expressionism:28},{Fauvism:17},{Naïve Art (Primitivism):2},{Neo-Impressionism:18},{Neoclassicism:33},{Post-Impressionism:22},{Unknown:1}"", ""art500k_movements"": ""{Post-Impressionism:129}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Mikhail Larionov,Emily Carr,"", ""wikiart_pictures_count"": 136}","portraits/thumbs/Andr__Derain_thumb.jpg"
|
||||
7,"Andy Warhol",1928,1987,26,"portraits/Andy_Warhol.jpg","; August 6, 1928 – February 22, 1987) was an American artist and filmmaker. Widely regarded as the most important artist of the second half of the 20th century, Warhol's practice spanned various media, including painting, filmmaking, photography, publishing, and performance art.","Andy Warhol ( ; born Andrew Warhola Jr.; August 6, 1928 – February 22, 1987) was an American artist and filmmaker. Widely regarded as the most important artist of the second half of the 20th century, Warhol's practice spanned various media, including painting, filmmaking, photography, publishing, and performance art. A leading figure in the Pop art movement, his work explores the relationship between advertising, consumerism, mass media, and celebrity culture. His embrace of mechanical reproduction challenged traditional boundaries between high and low culture. He is also credited with popularizing the expression ""15 minutes of fame.""
|
||||
Born to working-class Rusyn immigrant parents in Pittsburgh, Warhol began his career as a commercial artist in New York before transitioning to fine art. Among his best-known early silkscreen paintings are Campbell's Soup Can (1962), Marilyn Diptych (1962), and Coca-Cola (3) (1962). In the mid-1960s, Warhol began devoting his attention to creating experimental films such as Blow Job (1964) and Empire (1965). He subsequently directed a number of underground films—including Chelsea Girls (1966), Four Stars (1967), and Blue Movie (1969)—featuring a shifting group of personalities known as Warhol superstars. His studio, the Factory, became a hub for avant-garde experimentation, bringing together drag queens, poets, bohemians, musicians, and wealthy patrons. Warhol also managed the influential rock band the Velvet Underground, who performed at his Exploding Plastic Inevitable (1966–67) multimedia events.
|
||||
After Warhol survived an assassination attempt in 1968, the Factory evolved into a business enterprise. He founded Interview magazine, produced the play Pork (1971), and published various books such as The Philosophy of Andy Warhol (1975) and Popism (1980). He executed several series of paintings—notably Mao (1972–73), Athletes (1977), and Last Supper (1985–86)—and commissioned portraiture, while expanding into television with Andy Warhol's TV (1980–83) and Andy Warhol's Fifteen Minutes (1985–87). He meticulously documented his social life through photography and daily recordings, published posthumously as The Andy Warhol Diaries (1989). Warhol died of cardiac arrhythmia at the age of 58 following gallbladder surgery in 1987.
|
||||
Warhol has been described as the ""bellwether of the art market"", with several of his works ranking among the most expensive paintings ever sold. In 2013, Silver Car Crash (Double Disaster) (1963) sold for $105 million. In 2022, Shot Sage Blue Marilyn (1964) sold for $195 million, the highest price ever paid at auction for a work by an American artist. Warhol has been the subject of numerous retrospective exhibitions, books, and documentary films. The Andy Warhol Museum in Pittsburgh, which holds an extensive permanent collection of his art and archives, is the largest in the United States dedicated to a single artist.","Andy Warhol",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Expressionism, Naïve Art (Primitivism), Pop Art, Unknown"", ""movement"": ""Pop Art"", ""last_year"": 1988, ""birth_year"": 1928, ""death_year"": 1987, ""first_year"": 1953, ""pupils_raw"": null, ""birth_place"": ""Pittsburgh"", ""citizenship"": ""United States of America"", ""death_place"": ""New York City"", ""nationality"": ""American"", ""occupations"": ""drawer, installation artist, diarist, producer, screenwriter, photographer, merchant, cinematographer, author, illustrator, painter, sculptor, filmmaker, graphic artist, film director, film producer, visual artist, designer, performance artist, printmaker"", ""teachers_raw"": null, ""painting_school"": ""The Factory"", ""styles_extended"": ""{Abstract Expressionism:3},{Naïve Art (Primitivism):1},{Pop Art:224},{Unknown:3}"", ""art500k_movements"": ""{Pop Art:222}"", ""influenced_by_raw"": ""Robert Rauschenberg,Jasper Johns,Marcel Duchamp,Frank Stella,John Cage,Georgia O'Keeffe,Man Ray,Dada,Abstract Expressionism,"", ""influenced_on_raw"": ""Damien Hirst,Jeff Koons,Gerhard Richter,Pop Art,Neo-Expressionism,Wolfgang Tillmans,"", ""wikiart_pictures_count"": 231}","portraits/thumbs/Andy_Warhol_thumb.jpg"
|
||||
11,"Artemisia Gentileschi",1593,1656,8,"portraits/Artemisia_Gentileschi.jpg","Artemisia Lomi Gentileschi (US: JEN-til-ESK-ee, -teel-; Italian: [arteˈmiːzja ˈlɔːmi dʒentiˈleski]; 8 July 1593 – after January 1654) was an Italian Baroque painter. Gentileschi is considered among the most accomplished 17th-century artists, initially working in the style of Caravaggio.","Artemisia Lomi Gentileschi (US: JEN-til-ESK-ee, -teel-; Italian: [arteˈmiːzja ˈlɔːmi dʒentiˈleski]; 8 July 1593 – after January 1654) was an Italian Baroque painter. Gentileschi is considered among the most accomplished 17th-century artists, initially working in the style of Caravaggio. She was producing professional work by the age of 15. In an era when women had few opportunities to pursue artistic training or work as professional artists, Gentileschi was the first woman to become a member of the Accademia di Arte del Disegno in Florence and she had an international clientele. Gentileschi worked as an expatriate painter in the court of Charles I of England from 1638 to 1642, but she is thought to have fled the country in the early phases of the English Civil War. Her whereabouts over the following years are unknown, but she resurfaced in Naples during 1649. Her last known letter to one of her mentors was dated to 1650 and it indicates that she was still working as an artist. Her time of death is disputed, but her last known commission was in January 1654.
|
||||
Many of Gentileschi's paintings feature women from myths, allegories, and the Bible, including victims, suicides, and warriors. Some of her best-known subjects are Susanna and the Elders (particularly 1610 version in Schloss Weißenstein, Pommersfelden), Judith Slaying Holofernes (her 1614–1620 version is in the Uffizi gallery), and Judith and Her Maidservant (her 1625 work is in the Detroit Institute of Arts).
|
||||
|
||||
Gentileschi was known for being able to depict the female figure with great naturalism and for her skill in handling colour to express dimension and drama.
|
||||
Her achievements as an artist were long overshadowed by the story of her rape at around 18 years old by Agostino Tassi and her being tortured to give evidence during his subsequent trial. For many years Gentileschi was regarded as a curiosity, but her life and art have been reexamined by scholars in the 20th and 21st centuries, with the recognition of her talents exemplified by major exhibitions at internationally esteemed fine art institutions, such as the National Gallery in London.","Artemisia Gentileschi",1500,False,False,"{""gender"": ""female"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Tenebrism, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1649, ""birth_year"": 1596, ""death_year"": 1653, ""first_year"": 1610, ""pupils_raw"": null, ""birth_place"": ""Rome"", ""citizenship"": null, ""death_place"": ""Naples"", ""nationality"": ""Italian"", ""occupations"": ""artist, painter"", ""teachers_raw"": ""Orazio Gentileschi,"", ""painting_school"": ""Florentine School,Caravaggisti"", ""styles_extended"": ""{Baroque:7},{Tenebrism:5},{Unknown:9}"", ""art500k_movements"": ""{Baroque:19}"", ""influenced_by_raw"": ""Caravaggio,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 21}","portraits/thumbs/Artemisia_Gentileschi_thumb.jpg"
|
||||
12,"Aubrey Beardsley",1872,1898,16,"portraits/Aubrey_Beardsley.jpg","Aubrey Vincent Beardsley ( BEERDZ-lee; 21 August 1872 – 16 March 1898) was an English illustrator and author. His black ink drawings were influenced by Japanese woodcuts, and depicted the grotesque, the decadent, and the erotic.","Aubrey Vincent Beardsley ( BEERDZ-lee; 21 August 1872 – 16 March 1898) was an English illustrator and author. His black ink drawings were influenced by Japanese woodcuts, and depicted the grotesque, the decadent, and the erotic. He was a leading figure in the aesthetic movement, which also included Oscar Wilde and James McNeill Whistler. Beardsley's contribution to the development of the Art Nouveau and poster styles was significant despite his early death from tuberculosis. He is one of the important Modern Style figures.","Aubrey Beardsley",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern)"", ""movement"": ""Art Nouveau"", ""last_year"": 1898, ""birth_year"": 1872, ""death_year"": 1898, ""first_year"": 1892, ""pupils_raw"": null, ""birth_place"": ""Brighton"", ""citizenship"": ""United Kingdom of Great Britain and Ireland"", ""death_place"": ""Menton"", ""nationality"": ""British"", ""occupations"": ""exlibrist, lithographer, painter, poster artist, illustrator, author, poet, writer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Art Nouveau (Modern):239}"", ""art500k_movements"": ""{Aestheticism,Art Nouveau:239}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Vsevolod Maksymovych,"", ""wikiart_pictures_count"": 239}","portraits/thumbs/Aubrey_Beardsley_thumb.jpg"
|
||||
94,"Vincent van Gogh",1853,1890,14,"portraits/Vincent_van_Gogh.jpg","Vincent Willem van Gogh (Dutch: [ˈvɪnsɛnt ˈʋɪləɱ vɑŋ ˈɣɔx] ; 30 March 1853 – 29 July 1890) was a Dutch Post-Impressionist painter who is among the most famous and influential figures in the history of Western art. In just over a decade he created about 2,100 artworks, including around 860 oil paintings, most of them in the last two years of his life.","Vincent Willem van Gogh (Dutch: [ˈvɪnsɛnt ˈʋɪləɱ vɑŋ ˈɣɔx] ; 30 March 1853 – 29 July 1890) was a Dutch Post-Impressionist painter who is among the most famous and influential figures in the history of Western art. In just over a decade he created about 2,100 artworks, including around 860 oil paintings, most of them in the last two years of his life. They include landscapes, still lifes, portraits and self-portraits, and are characterised by bold colours and dramatic, impulsive and expressive brushwork that contributed to the foundations of modern art. His suicide at 37 followed years of mental illness and poverty.
|
||||
Born into an upper-middle-class family, Van Gogh drew as a child and was serious, quiet and thoughtful. As a young man he worked as an art dealer, often travelling, but became depressed after he was transferred to London. He turned to religion, and spent time as a Protestant missionary in southern Belgium. He drifted in ill health and solitude before taking up painting in 1881, having moved back home with his parents. His younger brother Theo supported him financially, and the two kept up a long correspondence by letter. His early works, mostly still lifes and depictions of peasant labourers, contain few signs of the vivid colour that distinguished his later work. In 1886, he moved to Paris, where he met members of the avant-garde, including Émile Bernard and Paul Gauguin, who were reacting against the Impressionist sensibility. As his work developed he created a new approach to still lifes and local landscapes. His paintings grew brighter in colour as he developed a style that became fully realised during his stay in Arles in the south of France in 1888. During this period he broadened his subject matter to include series of olive trees, wheat fields and sunflowers.
|
||||
Van Gogh suffered from psychotic episodes and delusions and though he worried about his mental stability, he often neglected his physical health, did not eat properly and drank heavily. His friendship with Gauguin ended after a confrontation with a razor, when in a rage, he severed part of his own left ear. He spent time in psychiatric hospitals, including a period at Saint-Rémy. After he discharged himself and moved to the Auberge Ravoux in Auvers-sur-Oise near Paris, he came under the care of the homoeopathic doctor Paul Gachet. His depression continued and on 27 July 1890, Van Gogh shot himself in the chest with a revolver. He died from his injuries two days later.
|
||||
Van Gogh was unsuccessful during his lifetime and was considered a madman and a failure. He became famous after his suicide, and exists in the public imagination as the quintessential misunderstood genius, the artist ""where discourses on madness and creativity converge"". His reputation began to grow in the early 20th century as elements of his painting style came to be incorporated by the Fauves and German Expressionists. He attained widespread critical, commercial and popular success over the ensuing decades, and is remembered as an important but tragic painter, whose troubled personality typifies the romantic ideal of the tortured artist.","Vincent van Gogh",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cloisonnism, Impressionism, Japonism, Neo-Impressionism, Pointillism, Post-Impressionism, Realism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1890, ""birth_year"": 1853, ""death_year"": 1890, ""first_year"": 1882, ""pupils_raw"": null, ""birth_place"": ""Zundert"", ""citizenship"": ""Kingdom of the Netherlands"", ""death_place"": ""Auvers-sur-Oise"", ""nationality"": null, ""occupations"": ""painter, printmaker, drawer, art dealer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Cloisonnism:11},{Impressionism:2},{Japonism:15},{Neo-Impressionism:83},{Pointillism:3},{Post-Impressionism:924},{Realism:887},{Unknown:6}"", ""art500k_movements"": ""{Neo-impressionism:2},{Post-Impressionism:2}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 1931}","portraits/thumbs/Vincent_van_Gogh_thumb.jpg"
|
||||
16,"Caspar David Friedrich",1774,1840,11,"portraits/Caspar_David_Friedrich.jpg","Caspar David Friedrich (German: [ˌkaspaʁ ˌdaːvɪt ˈfʁiːdʁɪç] ; 5 September 1774 – 7 May 1840) was a German Romantic landscape painter, generally considered the most important German artist of his generation, whose often symbolic, and anti-classical work, conveys a subjective, emotional response to the natural world. Friedrich's paintings often set contemplative human figures silhouetted against night skies, morning mists, barren trees or Gothic ruins.","Caspar David Friedrich (German: [ˌkaspaʁ ˌdaːvɪt ˈfʁiːdʁɪç] ; 5 September 1774 – 7 May 1840) was a German Romantic landscape painter, generally considered the most important German artist of his generation, whose often symbolic, and anti-classical work, conveys a subjective, emotional response to the natural world. Friedrich's paintings often set contemplative human figures silhouetted against night skies, morning mists, barren trees or Gothic ruins. Art historian Christopher John Murray described their presence, in diminished perspective, amid expansive landscapes, as reducing the figures to a scale that directs ""the viewer's gaze towards their metaphysical dimension"".
|
||||
Friedrich was born in the town of Greifswald on the Baltic Sea in what was at the time Swedish Pomerania. He studied in Copenhagen from 1794 to 1798, before settling in Dresden. He came of age during a period when, across Europe, a growing disillusionment with materialistic society was giving rise to a new appreciation of spirituality. This shift was often expressed through a reevaluation of the natural world, as artists such as Friedrich, J. M. W. Turner and John Constable sought to depict nature as a ""divine creation, to be set against the artifice of human civilization"".
|
||||
Friedrich's work brought him renown early in his career. Contemporaries such as the French sculptor David d'Angers spoke of him as having discovered ""the tragedy of landscape"". His work nevertheless fell from favour during his later years, and he died in obscurity. As Germany moved towards modernisation in the late 19th century, a new sense of urgency characterised its art, and Friedrich's contemplative depictions of stillness came to be seen as products of a bygone age.
|
||||
The early 20th century brought a renewed appreciation of his art, beginning in 1906 with an exhibition of thirty-two of his paintings in Berlin. His work influenced Expressionist artists and later Surrealists and Existentialists. The rise of Nazism in the early 1930s saw a resurgence in Friedrich's popularity, but this was followed by a sharp decline as his paintings were, by association with the Nazi movement, considered to be promoting German nationalism. Since the 1970s, Friedrich has experienced a major international revival and is regarded as one of the most important artists of German Romanticism and a national cultural icon.","Caspar David Friedrich",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1840, ""birth_year"": 1774, ""death_year"": 1840, ""first_year"": 1794, ""pupils_raw"": ""Winterlandschaft mit Kirche,Der Mönch am Meer,"", ""birth_place"": ""Greifswald"", ""citizenship"": ""Kingdom of Prussia"", ""death_place"": ""Dresden"", ""nationality"": null, ""occupations"": ""painter, sculptor, printmaker, architectural draftsperson, drawer, watercolorist, landscape painter"", ""teachers_raw"": ""Christian August Lorentzen,University of Greifswald, Greifswald, Germany,Royal Danish Academy of Fine Arts, Copenhagen, Denmark,Dresden Academy of Fine Arts, Dresden, Germany,Akademie der Künste Berlin, Berlin, Germany,"", ""painting_school"": null, ""styles_extended"": ""{Romanticism:145},{Unknown:10}"", ""art500k_movements"": ""{Romanticism:128}"", ""influenced_by_raw"": ""Arnold Böcklin,Arkhip Kuindzhi,Ivan Shishkin,Mark Rothko,Gerhard Richter,Gotthard Graubner,Hudson River School,Surrealism,Edvard Munch,Knud Baade,"", ""influenced_on_raw"": ""Arnold Böcklin,Arkhip Kuindzhi,Ivan Shishkin,Mark Rothko,Gerhard Richter,Gotthard Graubner,Hudson River School,Surrealism,Edvard Munch,Knud Baade,"", ""wikiart_pictures_count"": 155}","portraits/thumbs/Caspar_David_Friedrich_thumb.jpg"
|
||||
19,"Claude Monet",1840,1926,13,"portraits/Claude_Monet.jpg","Oscar-Claude Monet (UK: , US: ; French: [klod mɔnɛ]; 14 November 1840 – 5 December 1926) was a French painter and founder of Impressionism who is seen as a key precursor to modernism, especially in his attempts to paint nature as he perceived it. During his long career, he was the most consistent and prolific practitioner of Impressionism's philosophy of expressing one's perceptions of nature, especially as applied to plein air (outdoor) landscape painting.","Oscar-Claude Monet (UK: , US: ; French: [klod mɔnɛ]; 14 November 1840 – 5 December 1926) was a French painter and founder of Impressionism who is seen as a key precursor to modernism, especially in his attempts to paint nature as he perceived it. During his long career, he was the most consistent and prolific practitioner of Impressionism's philosophy of expressing one's perceptions of nature, especially as applied to plein air (outdoor) landscape painting. The term ""Impressionism"" is derived from the title of his painting Impression, Sunrise (Impression, soleil levant), which was exhibited in 1874 at the First Impressionist Exhibition, initiated by Monet and a number of like-minded artists as an alternative to the Salon.
|
||||
Monet was raised in Le Havre, Normandy, and became interested in the outdoors and drawing from an early age. Although his mother, Louise-Justine Aubrée Monet, supported his ambitions to be a painter, his father, Claude-Adolphe, disapproved and wanted him to pursue a career in business. He was very close to his mother, but she died in January 1857 when he was sixteen years old, and he was sent to live with his childless, widowed but wealthy aunt, Marie-Jeanne Lecadre. He went on to study at the Académie Suisse, and under the academic history painter Charles Gleyre, where he was a classmate of Auguste Renoir. His early works include landscapes, seascapes, and portraits, but attracted little attention. A key early influence was Eugène Boudin, who introduced him to the concept of plein air painting. From 1883, Monet lived in Giverny, also in northern France, where he purchased a house and property and began a vast landscaping project, including a water-lily pond.
|
||||
Monet's ambition to document the French countryside led to a method of painting the same scene many times so as to capture the changing of light and the passing of the seasons. Among the best-known examples are his series of haystacks (1890–1891), paintings of Rouen Cathedral (1892–1894), and the paintings of water lilies in his garden in Giverny, which occupied him for the last 20 years of his life. Frequently exhibited and successful during his lifetime, Monet's fame and popularity soared in the second half of the 20th century when he became one of the world's most famous painters and a source of inspiration for a burgeoning group of artists.","Claude Monet",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Academicism, Impressionism, Japonism, Realism, Unknown"", ""movement"": ""Impressionism"", ""last_year"": 1926, ""birth_year"": 1840, ""death_year"": 1926, ""first_year"": 1858, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Giverny"", ""nationality"": ""French"", ""occupations"": ""painter, graphic artist"", ""teachers_raw"": ""Eugene Boudin,Charles Gleyre,"", ""painting_school"": null, ""styles_extended"": ""{Academicism:1},{Impressionism:1341},{Japonism:1},{Realism:12},{Unknown:12}"", ""art500k_movements"": ""{Modern art:3},{Impressionism:1340}"", ""influenced_by_raw"": ""Gustave Courbet,Charles-Francois Daubigny,John Constable,J.M.W. Turner,Jean-Francois Millet,Alfred Sisley,Jacob van Ruisdael,"", ""influenced_on_raw"": ""Childe Hassam,Robert Delaunay,Wassily Kandinsky,Frank W. Benson,"", ""wikiart_pictures_count"": 1367}","portraits/thumbs/Claude_Monet_thumb.jpg"
|
||||
20,"Diego Velázquez",1599,1660,8,"portraits/Diego_Vel_zquez.jpg","Diego Rodríguez de Silva y Velázquez (baptised 6 June 1599 – 6 August 1660) was a Spanish Baroque painter, the leading artist in the court of King Philip IV of Spain and Portugal, and of the Spanish Golden Age. He is generally considered one of the greatest artists in the history of Western art.","Diego Rodríguez de Silva y Velázquez (baptised 6 June 1599 – 6 August 1660) was a Spanish Baroque painter, the leading artist in the court of King Philip IV of Spain and Portugal, and of the Spanish Golden Age. He is generally considered one of the greatest artists in the history of Western art.
|
||||
He was an individualistic artist of the Baroque period (c. 1600–1750). He began to paint in a precise tenebrist style, later developing a freer manner characterized by bold brushwork. In addition to numerous renditions of scenes of historical and cultural significance, he painted scores of portraits of the Spanish royal family and commoners, culminating in his masterpiece Las Meninas (1656).
|
||||
Velázquez's paintings became a model for 19th century realist and impressionist painters. In the 20th century, artists such as Pablo Picasso, Salvador Dalí, and Francis Bacon paid tribute to Velázquez by reinterpreting some of his most iconic images.
|
||||
Most of his work entered the Spanish royal collection, and by far the best collection is in the Museo del Prado in Madrid, although some portraits were sent abroad as diplomatic gifts, especially to the Austrian Habsburgs.","Diego Velázquez",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Unknown"", ""movement"": ""Baroque"", ""last_year"": 1660, ""birth_year"": 1599, ""death_year"": 1660, ""first_year"": 1616, ""pupils_raw"": null, ""birth_place"": ""Seville"", ""citizenship"": ""Spain"", ""death_place"": ""Madrid"", ""nationality"": ""Spanish"", ""occupations"": ""painter, artist, conquistador, military personnel, actor, television actor, researcher"", ""teachers_raw"": ""Francisco Pacheco,"", ""painting_school"": ""Seville School"", ""styles_extended"": ""{Baroque:93},{Unknown:41}"", ""art500k_movements"": ""{Baroque:134}"", ""influenced_by_raw"": ""Caravaggio,Titian,"", ""influenced_on_raw"": ""Gustave Courbet,Francis Bacon,Ilya Repin,Edouard Manet,Joshua Reynolds,Gerard Terborch,Oleksandr Murashko,Francisco Goya,Carolus-Duran,John Singer Sargent,Piotr Michałowski,Frank W. Benson,"", ""wikiart_pictures_count"": 134}","portraits/thumbs/Diego_Vel_zquez_thumb.jpg"
|
||||
23,"Edgar Degas",1834,1917,13,"portraits/Edgar_Degas.jpg","Edgar Degas (UK: , US: ; born Hilaire-Germain-Edgar De Gas, French: [ilɛːʁ ʒɛʁmɛ̃ ɛdɡaʁ də ɡa]; 19 July 1834 – 27 September 1917) was a French Impressionist artist famous for his pastel drawings and oil paintings.
|
||||
Degas also produced wax sculptures, prints, and drawings.","Edgar Degas (UK: , US: ; born Hilaire-Germain-Edgar De Gas, French: [ilɛːʁ ʒɛʁmɛ̃ ɛdɡaʁ də ɡa]; 19 July 1834 – 27 September 1917) was a French Impressionist artist famous for his pastel drawings and oil paintings.
|
||||
Degas also produced wax sculptures, prints, and drawings. Degas is especially identified with the subject of dance; more than half of his works depict dancers. Although Degas is regarded as one of the founders of Impressionism, he rejected the term, preferring to be called a realist, and did not paint outdoors as many Impressionists did.
|
||||
Degas was a superb draftsman, and particularly masterly in depicting movement, as can be seen in his rendition of dancers and bathing female nudes. In addition to ballet dancers and bathing women, Degas painted racehorses and racing jockeys, as well as portraits. His portraits are notable for their psychological complexity and their portrayal of human isolation.
|
||||
At the beginning of his career, Degas wanted to be a history painter, a calling for which he was well prepared by his rigorous academic training and close study of classical Western art. In his early thirties he changed course, and by bringing the traditional methods of a history painter to bear on contemporary subject matter, he became a classical painter of modern life.","Edgar Degas",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Japonism, Neoclassicism, Realism, Unknown"", ""movement"": ""Realism"", ""last_year"": 1956, ""birth_year"": 1834, ""death_year"": 1917, ""first_year"": 1844, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""photographer, poet, painter, sculptor, printmaker, drawer, lithographer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Impressionism:601},{Japonism:3},{Neoclassicism:1},{Realism:14},{Unknown:7}"", ""art500k_movements"": ""{Realism:1},{Modern art:4},{Impressionism:2},{Impressionism,Realism:617}"", ""influenced_by_raw"": ""Jean Auguste Dominique Ingres,Michelangelo,Raphael,Titian,Edouard Manet,Eugene Delacroix,"", ""influenced_on_raw"": ""Edward Hopper,Henri Gervex,Paul Gauguin,Mary Cassatt,Walter Sickert,Henri de Toulouse-Lautrec,Pierre-Auguste Renoir,Aaron Shikler,"", ""wikiart_pictures_count"": 626}","portraits/thumbs/Edgar_Degas_thumb.jpg"
|
||||
25,"Edvard Munch",1863,1944,18,"portraits/Edvard_Munch.jpg","Edvard Munch ( MUUNK; Norwegian: [ˈɛ̀dvɑɖ ˈmʊŋk] ; 12 December 1863 – 23 January 1944) was a Norwegian painter. His 1893 work The Scream has become one of the most iconic and acclaimed images in all of Western art.","Edvard Munch ( MUUNK; Norwegian: [ˈɛ̀dvɑɖ ˈmʊŋk] ; 12 December 1863 – 23 January 1944) was a Norwegian painter. His 1893 work The Scream has become one of the most iconic and acclaimed images in all of Western art.
|
||||
His childhood was overshadowed by illness, bereavement and the dread of inheriting a mental condition that ran in the family. Studying at the Royal School of Art and Design in Kristiania (Oslo), Munch began to live a bohemian life under the influence of the nihilist Hans Jæger, who urged him to paint his own emotional and psychological state ('soul painting'); from this emerged his distinctive style.
|
||||
Travel brought new influences and outlets. In Paris, he learned much from Paul Gauguin, Vincent van Gogh and Henri de Toulouse-Lautrec, especially their use of color. In Berlin, he met the Swedish dramatist August Strindberg, whom he painted, as he embarked on a major series of paintings he would later call The Frieze of Life, depicting a series of deeply felt themes such as love, anxiety, jealousy and betrayal, steeped in atmosphere.
|
||||
The Scream was conceived in Kristiania. According to Munch, he was out walking at sunset, when he 'heard the enormous, infinite scream of nature'. The painting's agonized face is widely identified with the angst of the modern person. Between 1893 and 1910, he made two painted versions and two in pastels, as well as a number of prints. One of the pastels would eventually command the fourth highest nominal price paid for a painting at auction.
|
||||
|
||||
As his fame and wealth grew, his emotional state remained insecure. He briefly considered marriage, but could not commit himself. A mental breakdown in 1908 forced him to give up heavy drinking, and he was cheered by his increasing acceptance by the people of Kristiania and exposure in the city's museums. His later years were spent working in peace and privacy. Although his works were banned in Nazi-occupied Europe, most of them survived World War II, securing him a legacy.
|
||||
Edvard Munch displayed a remarkable talent for drawing from an early age, yet he received minimal formal artistic training.
|
||||
A pivotal influence on his artistic growth was his association with the Kristiania Bohème—a progressive circle of writers and artists in Kristiania (the former name of Oslo). The group's members championed free love and openly rejected the narrow-minded conventions of bourgeois society.
|
||||
Among the more established painters in this circle, Christian Krohg provided Munch with valuable instruction as well as ongoing encouragement and support.
|
||||
Edvard Munch was highly active in the fields of graphic art and printmaking, producing a substantial body of work using intaglio techniques—such as etching, drypoint, and aquatint—on copper plates. Beginning in 1894, he embarked on engraving directly onto copper and, largely self-taught and without formal instruction, created numerous masterpieces in this medium.","Edvard Munch",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Expressionism, Impressionism, Realism, Symbolism, Unknown"", ""movement"": ""Symbolism"", ""last_year"": 1944, ""birth_year"": 1863, ""death_year"": 1944, ""first_year"": 1881, ""pupils_raw"": null, ""birth_place"": ""Ådalsbruk"", ""citizenship"": ""Norway"", ""death_place"": ""Ekely"", ""nationality"": ""Norwegian"", ""occupations"": ""painter, graphic artist, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": ""Leon Bonnat,"", ""painting_school"": ""Berlin Secession,Degenerate art"", ""styles_extended"": ""{Expressionism:157},{Impressionism:11},{Realism:6},{Symbolism:12},{Unknown:9}"", ""art500k_movements"": ""{Symbolism,Expressionism:188}"", ""influenced_by_raw"": ""Paul Gauguin,Vincent van Gogh,Henri de Toulouse-Lautrec,Georges Seurat,Caspar David Friedrich,"", ""influenced_on_raw"": ""Egon Schiele,Wassily Kandinsky,Ernst Ludwig Kirchner,Max Beckmann,Paul Klee,Henri Matisse,Expressionism,Surrealism,"", ""wikiart_pictures_count"": 195}","portraits/thumbs/Edvard_Munch_thumb.jpg"
|
||||
27,"Ernst Ludwig Kirchner",1880,1938,18,"portraits/Ernst_Ludwig_Kirchner.jpg","Ernst Ludwig Kirchner (6 May 1880 – 15 June 1938) was a German expressionist painter and printmaker. He was one of the founders of the artists group Die Brücke or ""The Bridge"", a key group leading to the foundation of Expressionism in 20th-century art.","Ernst Ludwig Kirchner (6 May 1880 – 15 June 1938) was a German expressionist painter and printmaker. He was one of the founders of the artists group Die Brücke or ""The Bridge"", a key group leading to the foundation of Expressionism in 20th-century art. Kirchner volunteered for army service in the First World War, but soon suffered a breakdown and was discharged. His work was branded as ""degenerate"" by the Nazis in 1933, and in 1937 more than 600 of his works were sold or destroyed.","Ernst Ludwig Kirchner",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Expressionism, Fauvism, Japonism, Neo-Impressionism, Unknown"", ""movement"": ""Expressionism"", ""last_year"": 1938, ""birth_year"": 1880, ""death_year"": 1938, ""first_year"": 1904, ""pupils_raw"": null, ""birth_place"": ""Aschaffenburg"", ""citizenship"": ""German Reich"", ""death_place"": ""Davos Frauenkirch"", ""nationality"": ""German"", ""occupations"": ""photographer, illustrator, painter, sculptor, graphic artist, visual artist, designer, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": ""Die Brücke (The Bridge),Degenerate art"", ""styles_extended"": ""{Cubism:9},{Expressionism:374},{Fauvism:1},{Japonism:4},{Neo-Impressionism:1},{Unknown:2}"", ""art500k_movements"": ""{Die Brücke:1},{German Expressionism:2},{Modern art:2},{Expressionism:392}"", ""influenced_by_raw"": ""Edvard Munch,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 391}","portraits/thumbs/Ernst_Ludwig_Kirchner_thumb.jpg"
|
||||
29,"Fra Angelico",1395,1455,4,"portraits/Fra_Angelico.jpg","Fra Giovanni da Fiesole (born Guido di Pietro; c. 1395 – 18 February 1455), known posthumously as Fra Angelico ( FRAH an-JEL-ik-oh, Italian: [ˈfra anˈdʒɛːliko]), was an Italian Dominican friar and painter active during the early Florentine Renaissance.","Fra Giovanni da Fiesole (born Guido di Pietro; c. 1395 – 18 February 1455), known posthumously as Fra Angelico ( FRAH an-JEL-ik-oh, Italian: [ˈfra anˈdʒɛːliko]), was an Italian Dominican friar and painter active during the early Florentine Renaissance.
|
||||
Angelico created a series of frescoes for the Dominican convent of San Marco in Florence, where he received the patronage of Cosimo de' Medici. His works include the San Marco Altarpiece and the Deposition of Christ, both made for the convent of San Marco. Painting exclusively religious subjects throughout his career, Angelico completed commissions in Rome under the patronage of Popes Eugene IV and Nicholas V. Angelico was a pioneer of the artistic trends that came to distinguish the early Renaissance, namely linear perspective and a greater attention to depth and form than had been practised in the late Medieval period.
|
||||
Angelico was beatified by Pope John Paul II in 1982. In 1984, John Paul declared him the patron of Catholic artists.","Fra Angelico",1300,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Early Renaissance, Unknown"", ""movement"": ""Early Renaissance"", ""last_year"": 1455, ""birth_year"": 1390, ""death_year"": 1455, ""first_year"": 1410, ""pupils_raw"": ""Benozzo Gozzoli,"", ""birth_place"": ""Vicchio"", ""citizenship"": null, ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""illuminator, painter, architectural draftsperson"", ""teachers_raw"": null, ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Early Renaissance:160},{Unknown:8}"", ""art500k_movements"": ""{Early Renaissance:167}"", ""influenced_by_raw"": ""Sienese School,"", ""influenced_on_raw"": null, ""wikiart_pictures_count"": 168}","portraits/thumbs/Fra_Angelico_thumb.jpg"
|
||||
31,"Francisco Goya",1746,1828,11,"portraits/Francisco_Goya.jpg","Francisco José de Goya y Lucientes (; Spanish: [fɾanˈθisko xoˈse ðe ˈɣoʝa i luˈθjentes]; 30 March 1746 – 16 April 1828) was a Spanish romantic painter and printmaker. He is considered the most important Spanish artist of the late 18th and early 19th centuries.","Francisco José de Goya y Lucientes (; Spanish: [fɾanˈθisko xoˈse ðe ˈɣoʝa i luˈθjentes]; 30 March 1746 – 16 April 1828) was a Spanish romantic painter and printmaker. He is considered the most important Spanish artist of the late 18th and early 19th centuries. His paintings, drawings, and engravings reflected contemporary historical upheavals and influenced important 19th- and 20th-century painters. Goya is often referred to as the last of the Old Masters and the first of the moderns. He is widely regarded as one of the most influential figures in the history of Western art.
|
||||
Goya was born in Fuendetodos, Aragon to a middle-class family in 1746. He studied painting from age 14 under José Luzán y Martínez and moved to Madrid to study with Anton Raphael Mengs. He married Josefa Bayeu in 1773. Goya became a court painter to the Spanish Crown in 1786 and this early portion of his career is marked by portraits of the Spanish aristocracy and royalty, and Rococo-style tapestry cartoons designed for the royal palace.
|
||||
Although Goya's letters and writings survive, little is known about his thoughts. He had a severe and undiagnosed illness in 1793 that left him deaf, after which his work became progressively darker and more pessimistic. His later easel and mural paintings, prints, and drawings appear to reflect a bleak outlook on personal, social, and political levels and contrast with his social climbing. He was appointed Director of the Royal Academy in 1795, the year Manuel Godoy made an unfavorable treaty with France. In 1799, Goya became Primer Pintor de Cámara (Prime Court Painter), the highest rank for a Spanish court painter. In the late 1790s, commissioned by Godoy, he completed his La maja desnuda, a remarkably daring nude for the time and clearly indebted to Diego Velázquez. In 1800–01, he painted Charles IV of Spain and His Family, also influenced by Velázquez.
|
||||
In 1807, Napoleon led the French army into the Peninsular War against Spain. Goya remained in Madrid during the war, which seems to have affected him deeply. Although he did not speak his thoughts in public, they can be inferred from his Disasters of War series of prints (although published 35 years after his death) and his 1814 paintings The Second of May 1808 and The Third of May 1808. Other works from his mid-period include the Caprichos and Los Disparates etching series, and a wide variety of paintings concerned with insanity, mental asylums, witches, fantastical creatures and religious and political corruption, all of which suggest that he feared for both his country's fate and his own mental and physical health.
|
||||
His late period culminates with the Black Paintings of 1819–1823, applied on oil on the plaster walls of his house the Quinta del Sordo (House of the Deaf Man) where, disillusioned by political and social developments in Spain, he lived in near isolation. Goya eventually abandoned Spain in 1824 to retire to the French city of Bordeaux, accompanied by his much younger maid and companion, Leocadia Weiss, who may have been his lover. There he completed his La Tauromaquia series and a number of other works. Following a stroke that left him paralyzed on his right side, Goya died and was buried on 16 April 1828 aged 82.","Francisco Goya",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Neoclassicism, Realism, Rococo, Romanticism, Unknown"", ""movement"": ""Romanticism"", ""last_year"": 1828, ""birth_year"": 1746, ""death_year"": 1828, ""first_year"": 1760, ""pupils_raw"": null, ""birth_place"": ""Fuendetodos"", ""citizenship"": ""Spain"", ""death_place"": ""Bordeaux"", ""nationality"": ""Spanish"", ""occupations"": ""lithographer, drawer, printmaker, etcher, painter"", ""teachers_raw"": ""José Luzán,Anton Raphael Mengs,"", ""painting_school"": null, ""styles_extended"": ""{Neoclassicism:1},{Realism:1},{Rococo:12},{Romanticism:349},{Unknown:43}"", ""art500k_movements"": ""{Romanticism:391}"", ""influenced_by_raw"": ""Albrecht Durer,Diego Velazquez,"", ""influenced_on_raw"": ""Pablo Picasso,Chaim Soutine,Roberto Montenegro,Jose Clemente Orozco,Pierre-Auguste Renoir,Max Klinger,"", ""wikiart_pictures_count"": 406}","portraits/thumbs/Francisco_Goya_thumb.jpg"
|
||||
32,"François Boucher",1703,1770,9,"portraits/Fran_ois_Boucher.jpg","François Boucher (UK: BOO-shay, US: boo-SHAY; French: [fʁɑ̃swa buʃe]; 29 September 1703 – 30 May 1770) was a French painter, draughtsman and etcher, who worked in the Rococo style. Boucher is known for his idyllic and voluptuous paintings on classical themes, decorative allegories, and pastoral scenes.","François Boucher (UK: BOO-shay, US: boo-SHAY; French: [fʁɑ̃swa buʃe]; 29 September 1703 – 30 May 1770) was a French painter, draughtsman and etcher, who worked in the Rococo style. Boucher is known for his idyllic and voluptuous paintings on classical themes, decorative allegories, and pastoral scenes. He was perhaps the most celebrated painter and decorative artist of the 18th century.","François Boucher",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Rococo, Unknown"", ""movement"": ""Rococo"", ""last_year"": 1770, ""birth_year"": 1703, ""death_year"": 1770, ""first_year"": 1726, ""pupils_raw"": ""Jacques-Louis David,"", ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""illustrator, painter, etcher, printmaker, drawer, tapestry designer, exlibrist, writer, curator, art historian, art collector"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Rococo:74},{Unknown:1}"", ""art500k_movements"": ""{Rococo:75}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Pierre-Auguste Renoir,"", ""wikiart_pictures_count"": 75}","portraits/thumbs/Fran_ois_Boucher_thumb.jpg"
|
||||
36,"Gino Severini",1883,1966,20,"portraits/Gino_Severini.jpg","Gino Severini (7 April 1883 – 26 February 1966) was an Italian painter and a leading member of the Futurist movement. For much of his life, he divided his time between Paris and Rome.","Gino Severini (7 April 1883 – 26 February 1966) was an Italian painter and a leading member of the Futurist movement. For much of his life, he divided his time between Paris and Rome. He was associated with neo-classicism and the ""return to order"" in the decade after the First World War. During his career, he worked in a variety of media, including mosaic and fresco. He showed his work at major exhibitions, including the Rome Quadrennial, and won art prizes from major institutions.","Gino Severini",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Divisionism, Futurism, Impressionism, Neoclassicism, Synthetic Cubism"", ""movement"": ""Cubism"", ""last_year"": 1965, ""birth_year"": 1883, ""death_year"": 1966, ""first_year"": 1908, ""pupils_raw"": null, ""birth_place"": ""Cortona"", ""citizenship"": ""Kingdom of Italy"", ""death_place"": ""Paris"", ""nationality"": ""Italian"", ""occupations"": ""architectural draftsperson, designer, glass artist, scenographer, jewelry designer, graphic artist, sculptor, painter, illustrator, mosaicist"", ""teachers_raw"": null, ""painting_school"": ""École de Paris,Novecento Italiano,Milan Futurist Group,Lacerba"", ""styles_extended"": ""{Cubism:13},{Divisionism:1},{Futurism:35},{Impressionism:2},{Neoclassicism:4},{Synthetic Cubism:3}"", ""art500k_movements"": ""{Cubism:1},{Modern art:2},{Futurism:3},{Cubism,Futurism:58}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 58}","portraits/thumbs/Gino_Severini_thumb.jpg"
|
||||
38,"Giorgione",1477,1510,6,"portraits/Giorgione.jpg","Giorgio Barbarelli da Castelfranco (1470s – 17 September 1510), known as Giorgione, was an Italian painter of the Venetian school during the High Renaissance, who died in his thirties. He is known for the elusive poetic quality of his work, though only about six surviving paintings are firmly attributed to him.","Giorgio Barbarelli da Castelfranco (1470s – 17 September 1510), known as Giorgione, was an Italian painter of the Venetian school during the High Renaissance, who died in his thirties. He is known for the elusive poetic quality of his work, though only about six surviving paintings are firmly attributed to him. The uncertainty surrounding the identity and meaning of his work has made Giorgione one of the most mysterious figures in European art.
|
||||
Together with his younger contemporary Titian, he founded the Venetian school of Italian Renaissance painting, characterised by its use of colour and mood. The school is traditionally contrasted with Florentine painting, which relied on a more linear disegno-led style.","Giorgione",1500,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""High Renaissance, Unknown"", ""movement"": ""High Renaissance"", ""last_year"": 1510, ""birth_year"": 1470, ""death_year"": 1510, ""first_year"": 1500, ""pupils_raw"": null, ""birth_place"": ""Castelfranco Veneto"", ""citizenship"": ""Republic of Venice"", ""death_place"": ""Venice"", ""nationality"": ""Italian"", ""occupations"": ""painter, visual artist"", ""teachers_raw"": ""Giovanni Bellini,"", ""painting_school"": ""Venetian School"", ""styles_extended"": ""{High Renaissance:43},{Unknown:8}"", ""art500k_movements"": ""{Venetian school:1},{High Renaissance:50},{Italian Renaissance:1},{Renaissance:1}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Venetian School,Titian,Lorenzo Lotto,Caravaggio,"", ""wikiart_pictures_count"": 51}","portraits/thumbs/Giorgione_thumb.jpg"
|
||||
39,"Giotto",1267,1337,3,"portraits/Giotto.jpg","Giotto di Bondone (Italian: [ˈdʒɔtto di bonˈdoːne]; c. 1267 – January 8, 1337), known mononymously as Giotto, was an Italian painter and architect from Florence during the Late Middle Ages.","Giotto di Bondone (Italian: [ˈdʒɔtto di bonˈdoːne]; c. 1267 – January 8, 1337), known mononymously as Giotto, was an Italian painter and architect from Florence during the Late Middle Ages. He worked during the Gothic and Proto-Renaissance period. Giotto's contemporary, the banker and chronicler Giovanni Villani, wrote that Giotto was ""the most sovereign master of painting in his time, who drew all his figures and their postures according to nature"" and of his publicly recognized ""talent and excellence"". Giorgio Vasari described Giotto as making a decisive break from the prevalent Byzantine style and as initiating ""the great art of painting as we know it today, introducing the technique of drawing accurately from life, which had been neglected for more than two hundred years"".
|
||||
Giotto's masterwork is the decoration of the Scrovegni Chapel, in Padua, also known as the Arena Chapel, which was completed around 1305. The fresco cycle depicts the Life of the Virgin and the Life of Christ. It is regarded as one of the supreme masterpieces of the Early Renaissance.
|
||||
The fact that Giotto painted the Arena Chapel and that he was chosen by the Commune of Florence in 1334 to design the new campanile (bell tower) of the Florence Cathedral are among the few certainties about his life. Almost every other aspect of it is subject to controversy: his birth date, his birthplace, his appearance, his apprenticeship, the order in which he created his works, whether he painted the famous frescoes in the Upper Basilica of Saint Francis in Assisi, and his burial place.","Giotto",1200,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Proto Renaissance"", ""movement"": ""Proto Renaissance"", ""last_year"": 1335, ""birth_year"": 1266, ""death_year"": 1337, ""first_year"": 1290, ""pupils_raw"": null, ""birth_place"": ""Vicchio"", ""citizenship"": ""Republic of Florence"", ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""architect, painter, sculptor, muralist, designer"", ""teachers_raw"": ""Cimabue,"", ""painting_school"": ""Florentine School"", ""styles_extended"": ""{Proto Renaissance:136}"", ""art500k_movements"": ""{Proto Renaissance:139}"", ""influenced_by_raw"": ""Byzantine Art,"", ""influenced_on_raw"": ""Proto Renaissance,Jose Clemente Orozco,Cennino Cennini,Paul Gauguin,Michelangelo,William Holman Hunt,Ambrogio Lorenzetti,Pietro Lorenzetti,Giovanni da Milano,Lorenzo Monaco,"", ""wikiart_pictures_count"": 136}","portraits/thumbs/Giotto_thumb.jpg"
|
||||
41,"Gustave Courbet",1819,1877,12,"portraits/Gustave_Courbet.jpg","Jean Désiré Gustave Courbet (UK: KOOR-bay; US: koor-BAY; French: [ɡystav kuʁbɛ]; 10 June 1819 – 31 December 1877) was a French painter who led the Realism movement in 19th-century French painting. Committed to painting only what he could see, he rejected academic convention and the Romanticism of the previous generation of visual artists.","Jean Désiré Gustave Courbet (UK: KOOR-bay; US: koor-BAY; French: [ɡystav kuʁbɛ]; 10 June 1819 – 31 December 1877) was a French painter who led the Realism movement in 19th-century French painting. Committed to painting only what he could see, he rejected academic convention and the Romanticism of the previous generation of visual artists. His independence set an example that was important to later artists, such as the Impressionists and the Cubists. Courbet occupies an important place in 19th-century French painting as an innovator and as an artist willing to make bold social statements through his work.
|
||||
Courbet's paintings of the late 1840s and early 1850s brought him his first recognition. They challenged convention by depicting unidealized peasants and workers, often on a grand scale traditionally reserved for paintings of religious or historical subjects. Courbet's subsequent paintings were mostly of a less overtly political character: landscapes, seascapes, hunting scenes, nudes, and still lifes. Courbet was imprisoned for six months in 1871 for his involvement with the Paris Commune and lived in exile in Switzerland from 1873 until his death four years later.","Gustave Courbet",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Baroque, Realism, Romanticism, Unknown"", ""movement"": ""Realism"", ""last_year"": 1877, ""birth_year"": 1819, ""death_year"": 1877, ""first_year"": 1830, ""pupils_raw"": null, ""birth_place"": ""Ornans"", ""citizenship"": ""France"", ""death_place"": ""La Tour-de-Peilz"", ""nationality"": ""French"", ""occupations"": ""painter, sculptor, communard, drawer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Baroque:1},{Realism:266},{Romanticism:13},{Unknown:9}"", ""art500k_movements"": ""{Realism:272}"", ""influenced_by_raw"": ""Rembrandt,Caravaggio,Diego Velazquez,Peter Paul Rubens,Francisco de Zurbaran,Baroque,Romanticism,Carolus-Duran,"", ""influenced_on_raw"": ""Edouard Manet,Claude Monet,Pierre-Auguste Renoir,Paul Cezanne,James McNeill Whistler,Impressionism,Post-Impressionism,Expressionism,Alfred Sisley,Lucian Freud,Chaim Soutine,Charles-Francois Daubigny,Andres de Santa Maria,Jules Bastien-Lepage,Eugène Burnand,"", ""wikiart_pictures_count"": 289}","portraits/thumbs/Gustave_Courbet_thumb.jpg"
|
||||
52,"Jan van Eyck",1390,1441,5,"portraits/Jan_van_Eyck.jpg","Jan van Eyck ( van EYEK; Dutch: [ˈjɑɱ vɑn ˈɛik]; c. before 1390 – 9 July 1441) was an early Netherlandish painter active in Bruges who was one of the early innovators of what became known as early Netherlandish painting, and one of the supreme figures of the Early Northern Renaissance.","Jan van Eyck ( van EYEK; Dutch: [ˈjɑɱ vɑn ˈɛik]; c. before 1390 – 9 July 1441) was an early Netherlandish painter active in Bruges who was one of the early innovators of what became known as early Netherlandish painting, and one of the supreme figures of the Early Northern Renaissance. Such was his legacy, that he has been called ""the inventor of oil-painting"" by Vasari, Ernst Gombrich, and others, although this claim is now considered an oversimplification.
|
||||
Surviving records date his birth at around 1380 or 1390, in Maaseik (then Maaseyck, hence his name) by the Limburg in present-day Belgium. He took employment in The Hague around 1422, when he was already a master painter with workshop assistants, and was employed as painter and valet de chambre to John III the Pitiless, ruler of the counties of Holland and Hainaut. Some time after John's death in 1425, he was appointed as court painter to Philip the Good, Duke of Burgundy, and worked in Lille before moving to Bruges in 1429, where he lived until his death. He was highly regarded by Philip, and was dispatched on several diplomatic visits abroad, including one to Lisbon in 1428 to discuss the possibility of a marriage contract between the duke and Isabella of Portugal.
|
||||
About 20 surviving paintings are confidently attributed to him, as well as the Ghent Altarpiece and the illuminated miniatures of the Turin-Milan Hours, all dated between 1432 and 1439. Ten are dated and signed with a variation of his motto ALS ICH KAN (As I (Eyck) can), a pun on his name, which he typically painted in Greek characters.
|
||||
Van Eyck painted both secular and religious subjects. His works include altarpieces, painted panels—diptychs (dismantled), triptychs, and polyptychs—and commissioned portraits. He was well paid by Philip, who wanted the painter to have the financial security and artistic freedom to paint ""whenever he pleased."" Van Eyck's early work shows influence from the International Gothic style, which he soon eclipsed, in part through a greater emphasis on naturalism and realism. He achieved a new level of virtuosity through his developments in the use of oil paint. His style and techniques profoundly altered the development of the Early Netherlandish school.","Jan van Eyck",1300,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Northern Renaissance, Unknown"", ""movement"": ""Northern Renaissance"", ""last_year"": 1441, ""birth_year"": 1390, ""death_year"": 1441, ""first_year"": 1422, ""pupils_raw"": ""Petrus Christus,"", ""birth_place"": ""Maaseik"", ""citizenship"": ""Southern Netherlands"", ""death_place"": ""Bruges"", ""nationality"": ""Flemish"", ""occupations"": ""architect, illuminator, painter, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": ""Flemish School"", ""styles_extended"": ""{Northern Renaissance:50},{Unknown:21}"", ""art500k_movements"": ""{Northern Renaissance:73}"", ""influenced_by_raw"": ""Gothic Art,Robert Campin,"", ""influenced_on_raw"": ""Antonello da Messina,Rogier van der Weyden,Petrus Christus,Hans Memling,"", ""wikiart_pictures_count"": 71}","portraits/thumbs/Jan_van_Eyck_thumb.jpg"
|
||||
57,"Jean-Honoré Fragonard",1732,1806,9,"portraits/Jean-Honor__Fragonard.jpg","Jean-Honoré Fragonard (French: [ʒɑ̃ ɔnɔʁe fʁaɡɔnaʁ]; 5 April 1732 – 22 August 1806) was a French painter and printmaker whose late Rococo manner was distinguished by remarkable facility, exuberance, and hedonism. One of the most prolific artists active in the last decades of the Ancien Régime, Fragonard produced more than 550 paintings (not counting drawings and etchings), of which only five are dated.","Jean-Honoré Fragonard (French: [ʒɑ̃ ɔnɔʁe fʁaɡɔnaʁ]; 5 April 1732 – 22 August 1806) was a French painter and printmaker whose late Rococo manner was distinguished by remarkable facility, exuberance, and hedonism. One of the most prolific artists active in the last decades of the Ancien Régime, Fragonard produced more than 550 paintings (not counting drawings and etchings), of which only five are dated. Among his most popular works are genre paintings conveying an atmosphere of intimacy and veiled eroticism.","Jean-Honoré Fragonard",1700,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Rococo, Unknown"", ""movement"": ""Rococo"", ""last_year"": 1790, ""birth_year"": 1732, ""death_year"": 1806, ""first_year"": 1750, ""pupils_raw"": null, ""birth_place"": ""Grasse"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""illustrator, painter, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Rococo:64},{Unknown:5}"", ""art500k_movements"": ""{Rococo:72},{Renaissance:1}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 69}","portraits/thumbs/Jean-Honor__Fragonard_thumb.jpg"
|
||||
61,"Kazimir Malevich",1879,1935,21,"portraits/Kazimir_Malevich.jpg","S. 11 February] 1879 – 15 May 1935) was a Russian avant-garde artist and art theorist, whose work and writings pioneered the development of abstract painting in the 20th century.","Kazimir Severinovich Malevich (23 February [O.S. 11 February] 1879 – 15 May 1935) was a Russian avant-garde artist and art theorist, whose work and writings pioneered the development of abstract painting in the 20th century. He is best known as the founder of Suprematism, a radically non-objective form of painting he introduced in 1915.
|
||||
Born in Kiev, modern-day Ukraine, to an ethnic Polish family, Malevich worked primarily in Russia and became a leading figure of the Russian avant-garde. His work has also been associated with the Ukrainian avant-garde. Early in his career, he worked in multiple styles, assimilating Impressionism, Symbolism, Fauvism, and Cubism through reproductions and the works acquired by contemporary Russian collectors. In the early 1910s, he exhibited alongside other Russian avant-garde artists, including Mikhail Larionov and Natalia Goncharova. In 1915, working in a Cubo-Futurist mode, Malevich developed Suprematism, a system of pure geometric abstraction on monochromatic grounds. His Black Square (1915), first shown at the Last Futurist Exhibition 0,10 in Petrograd, marked a decisive break with representational painting. He set out his theory in the brochure From Cubism and Futurism to Suprematism, published to accompany the exhibition.
|
||||
His trajectory mirrored the upheavals around the October Revolution of 1917. In 1918, Malevich began teaching in Vitebsk along with Marc Chagall. In 1919, he founded the UNOVIS artists collective and had a solo show at the Sixteenth State Exhibition in Moscow. His reputation spread westward with solo exhibitions in Warsaw and Berlin in 1927, the only time he ever left Russia. From 1928 to 1930 he taught at the Kiev Art Institute alongside Alexander Bogomazov, Victor Palmov, and Vladimir Tatlin, while publishing in the Kharkiv magazine Nova Generatsiia. In 1930, he was briefly arrested and interrogated by the OGPU in Leningrad. By the early 1930s, Stalin's restrictive cultural policy and the subsequent imposition of Socialist Realism had prompted Malevich to return to figuration and to paint in a representational style. Diagnosed with cancer in 1933, he was not allowed to leave the Soviet Union to seek treatment abroad. While constrained by his progressing illness and Stalin's cultural policies, Malevich painted and exhibited his work until the end of his life. He died on 15 May 1935, at age 56.
|
||||
His art and his writings influenced Eastern and Central European contemporaries such as El Lissitzky, Lyubov Popova, Alexander Rodchenko and Henryk Stażewski, as well as generations of later abstract artists, such as Ad Reinhardt and the Minimalists. He was celebrated posthumously in major exhibits at the Museum of Modern Art (1936), the Guggenheim Museum (1973), and the Stedelijk Museum in Amsterdam (1989), which has a large collection of his work. In the 1990s, the ownership claims of museums to many Malevich works began to be disputed by his heirs.","Kazimir Malevich",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern), Cloisonnism, Constructivism, Cubism, Cubo-Futurism, Expressionism, Fauvism, Futurism, Impressionism, Naïve Art (Primitivism), Neo-Suprematism, Neo-baroque, Neoclassicism, Pointillism, Post-Impressionism, Realism, Socialist Realism, Suprematism, Symbolism, Unknown"", ""movement"": ""Abstract Art"", ""last_year"": 1934, ""birth_year"": 1879, ""death_year"": 1935, ""first_year"": 1900, ""pupils_raw"": ""El Lissitzky,"", ""birth_place"": ""Kyiv"", ""citizenship"": ""Soviet Union"", ""death_place"": ""Saint Petersburg"", ""nationality"": ""Russian,Polish,Ukrainian"", ""occupations"": ""teacher, painter, pedagogue, sculptor, costume designer, art historian, scenographer, visual artist, designer, printmaker, drawer, art theorist"", ""teachers_raw"": ""Mykola Pymonenko,Vitebsk Art School, Vitebsk, Belarus,Kyiv Art School, Kyiv, Ukraine,National Academy of Visual Arts and Architecture, Kyiv, Ukraine,"", ""painting_school"": ""Jack of Diamonds,Soyuz Molodyozhi (Union of the Youth),Donkey's Tail (Osliniy khvost)"", ""styles_extended"": ""{Art Nouveau (Modern):2},{Cloisonnism:2},{Constructivism:25},{Cubism:31},{Cubo-Futurism:32},{Expressionism:6},{Fauvism:10},{Futurism:11},{Impressionism:22},{Naïve Art (Primitivism):27},{Neo-Suprematism:54},{Neo-baroque:1},{Neoclassicism:8},{Pointillism:5},{Post-Impressionism:5},{Realism:9},{Socialist Realism:1},{Suprematism:85},{Symbolism:11},{Unknown:6}"", ""art500k_movements"": ""{Suprematism,Abstract Art:359}"", ""influenced_by_raw"": ""Aristarkh Lentulov,"", ""influenced_on_raw"": ""Josef Albers,Ad Reinhardt,Nasreen Mohamedi,"", ""wikiart_pictures_count"": 353}","portraits/thumbs/Kazimir_Malevich_thumb.jpg"
|
||||
64,"Marcel Duchamp",1887,1968,23,"portraits/Marcel_Duchamp.jpg","Henri-Robert-Marcel Duchamp (UK: , US: ; French: [maʁsɛl dyʃɑ̃]; 28 July 1887 – 2 October 1968) was a French American artist, chess player, and inventor who played a key role in the development of the avant-garde in the United States and in New York City, where he spent the last 25 years of his life.
|
||||
Duchamp was the first artist to elevate a urinal to the status of an art form.","Henri-Robert-Marcel Duchamp (UK: , US: ; French: [maʁsɛl dyʃɑ̃]; 28 July 1887 – 2 October 1968) was a French American artist, chess player, and inventor who played a key role in the development of the avant-garde in the United States and in New York City, where he spent the last 25 years of his life.
|
||||
Duchamp was the first artist to elevate a urinal to the status of an art form. For his talk at the American Federation of the Arts in Houston, ""On the Creative Act,"" for which he labeled himself a 'mere artist,' Duchamp said, ""Let us consider two important factors, the two poles of the creation of art: the artist, on the one hand, and on the other the spectator who later becomes the posterity...if we give the attributes of a medium to the artist, we must then deny him the state of consciousness on the esthetic plane about what he is doing or why he is doing it. All his decisions in the artistic execution of the work rest with pure intuition and cannot be translated into a self-analysis, spoken or written, or even thought out.""
|
||||
Duchamp is commonly regarded, along with Pablo Picasso and Georges Braque, as one of the three artists who helped to establish the post-industrial perspective in art history, and his work is considered the progenitor of conceptual art. His definition of the art coefficient, ""an arithmatic relationship between the unexpressed but intended and the unintentionally expressed,"" lay the groundwork for a more recent generation of artists working through appropriation art, such as Andy Warhol, Beatrice Wood, Sherrie Levine, Richard Prince and Sturtevant.","Marcel Duchamp",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Conceptual Art, Cubism, Dada, Expressionism, Fauvism, Futurism, Kinetic Art, Op Art, Post-Impressionism, Surrealism, Symbolism, Unknown"", ""movement"": ""Dada"", ""last_year"": 1966, ""birth_year"": 1887, ""death_year"": 1968, ""first_year"": 1902, ""pupils_raw"": null, ""birth_place"": ""Blainville-Crevon"", ""citizenship"": ""United States of America"", ""death_place"": ""Neuilly-sur-Seine"", ""nationality"": ""French"", ""occupations"": ""photographer, actor, poet, librarian, artist, painter, sculptor, film director, visual artist, philosopher, designer, chess player, printmaker, architectural draftsperson, assemblage artist"", ""teachers_raw"": ""Académie Julian, Paris, France,"", ""painting_school"": ""Société des Artistes Indépendants (Society of Independent Artists),Section d'Or (Puteaux Group),Société Anonyme"", ""styles_extended"": ""{Conceptual Art:3},{Cubism:6},{Dada:17},{Expressionism:18},{Fauvism:6},{Futurism:3},{Kinetic Art:4},{Op Art:2},{Post-Impressionism:13},{Surrealism:8},{Symbolism:2},{Unknown:1}"", ""art500k_movements"": ""{Dada,Surrealism:81}"", ""influenced_by_raw"": ""Andy Warhol,Hiroshi Sugimoto,Damien Hirst,"", ""influenced_on_raw"": ""Andy Warhol,Hiroshi Sugimoto,Damien Hirst,designs-and-sketches,monuments-and-statues,female-portraits,double-portraits,female-nude,male-portraits,reading-and-writing,"", ""wikiart_pictures_count"": 83}","portraits/thumbs/Marcel_Duchamp_thumb.jpg"
|
||||
65,"Mark Rothko",1903,1970,25,"portraits/Mark_Rothko.jpg","Mark Rothko ( ROTH-koh; Markus Yakovlevich Rothkowitz until 1940; September 25, 1903 – February 25, 1970) was a Latvian-born American abstract painter. He is best known for his color field paintings that depicted irregular and painterly rectangular regions of color, which he produced from 1949 to 1970.","Mark Rothko ( ROTH-koh; Markus Yakovlevich Rothkowitz until 1940; September 25, 1903 – February 25, 1970) was a Latvian-born American abstract painter. He is best known for his color field paintings that depicted irregular and painterly rectangular regions of color, which he produced from 1949 to 1970. Although Rothko did not personally subscribe to any one school, he is associated with the American abstract expressionism movement of modern art.
|
||||
Born to a Jewish family in Daugavpils, Latvia, then part of the Russian Empire, Rothko emigrated with his parents and siblings to the United States, arriving at Ellis Island in late 1913 and originally settling in Portland, Oregon. He moved to New York City in 1923 where his youthful period of artistic production dealt primarily with urban scenery. In response to World War II, Rothko's art entered a transitional phase during the 1940s, where he experimented with mythological themes and Surrealism to express tragedy. Toward the end of the decade, Rothko painted canvases with regions of pure color which he further abstracted into rectangular color forms, the idiom he would use for the rest of his life.
|
||||
In his later career, Rothko executed several canvases for three different mural projects. The Seagram murals were to have decorated the Four Seasons Restaurant in the Seagram Building, but Rothko eventually grew disgusted with the idea that his paintings would be decorative objects for wealthy diners and refunded the lucrative commission, donating the paintings to museums, including the Tate Gallery. The Harvard Mural series was donated to a dining room in Harvard's Holyoke Center; their colors faded badly over time due to Rothko's use of the pigment lithol red together with regular sunlight exposure. The Harvard series has since been restored using a special lighting technique. Rothko contributed 14 canvases to a permanent installation at the Rothko Chapel, a non-denominational chapel in Houston, Texas.
|
||||
Although Rothko lived modestly for much of his life, the resale value of his paintings grew tremendously in the decades following his suicide in 1970. His painting No. 6 (Violet, Green and Red) sold in 2014 for $186 million.","Mark Rothko",1900,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Abstract Art, Abstract Expressionism, Color Field Painting, Expressionism, Minimalism, Surrealism"", ""movement"": ""Abstract Art"", ""last_year"": 1970, ""birth_year"": 1903, ""death_year"": 1970, ""first_year"": 1936, ""pupils_raw"": null, ""birth_place"": ""Daugavpils"", ""citizenship"": ""United States of America"", ""death_place"": ""Manhattan"", ""nationality"": ""American,Jewish"", ""occupations"": ""painter, university teacher, drawer"", ""teachers_raw"": null, ""painting_school"": ""New York School,Irascibles"", ""styles_extended"": ""{Abstract Art:4},{Abstract Expressionism:18},{Color Field Painting:104},{Expressionism:14},{Minimalism:9},{Surrealism:21}"", ""art500k_movements"": ""{Abstract Expressionism:161}"", ""influenced_by_raw"": ""Edward Hopper,Ad Reinhardt,Caspar David Friedrich,Salvador Dali,"", ""influenced_on_raw"": ""Helmut Federle,"", ""wikiart_pictures_count"": 170}","portraits/thumbs/Mark_Rothko_thumb.jpg"
|
||||
71,"Odilon Redon",1840,1916,15,"portraits/Odilon_Redon.jpg","Odilon Redon (born Bertrand Redon; French: [ɔdilɔ̃ ʁədɔ̃]; 20 April 1840 – 6 July 1916) was a French Symbolist draftsman, printmaker, and painter.
|
||||
Early in his career, both before and after fighting in the Franco-Prussian War, Redon worked almost exclusively in charcoal and lithography, works known as his noirs.","Odilon Redon (born Bertrand Redon; French: [ɔdilɔ̃ ʁədɔ̃]; 20 April 1840 – 6 July 1916) was a French Symbolist draftsman, printmaker, and painter.
|
||||
Early in his career, both before and after fighting in the Franco-Prussian War, Redon worked almost exclusively in charcoal and lithography, works known as his noirs. He gained recognition after his drawings were mentioned in the 1884 novel À rebours (Against Nature) by Joris-Karl Huysmans. During the 1890s, Redon began working in pastel and oil, which quickly became his favorite medium, abandoning his previous style of noirs completely after 1900. He developed a keen interest in Hindu and Buddhist religion and culture, which increasingly showed in his work.
|
||||
Redon is perhaps best known today for the dreamlike paintings created in the first decade of the 20th century, which were inspired by Japanese art and leaned toward abstraction. His work is considered a precursor to Surrealism.","Odilon Redon",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Art Nouveau (Modern), Impressionism, Post-Impressionism, Realism, Symbolism, Unknown"", ""movement"": ""Symbolism"", ""last_year"": 1916, ""birth_year"": 1840, ""death_year"": 1916, ""first_year"": 1865, ""pupils_raw"": null, ""birth_place"": ""Bordeaux"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""illustrator, painter, sculptor, printmaker, architectural draftsperson, drawer, lithographer"", ""teachers_raw"": null, ""painting_school"": ""Société des Artistes Indépendants (Society of Independent Artists)"", ""styles_extended"": ""{Art Nouveau (Modern):1},{Impressionism:11},{Post-Impressionism:4},{Realism:62},{Symbolism:376},{Unknown:1}"", ""art500k_movements"": ""{Symbolism:454},{Modern art:1}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 455}","portraits/thumbs/Odilon_Redon_thumb.jpg"
|
||||
76,"Paul Cézanne",1839,1906,14,"portraits/Paul_C_zanne.jpg","Paul Cézanne ( say-ZAN, UK also siz-AN, US also say-ZAHN; French: [pɔl sezan]; Occitan: Pau Cesana; 19 January 1839 – 22 October 1906) was a French Post-Impressionist painter whose work introduced new modes of representation, influenced avant-garde artistic movements of the early 20th century and formed the bridge between late 19th-century Impressionism and early 20th-century Cubism.
|
||||
While his early works were influenced by Romanticism—such as the murals in the Jas de Bouffan country house—and Realism, Cézanne arrived at a new pictorial language through intense examination of Impressionist forms of expression.","Paul Cézanne ( say-ZAN, UK also siz-AN, US also say-ZAHN; French: [pɔl sezan]; Occitan: Pau Cesana; 19 January 1839 – 22 October 1906) was a French Post-Impressionist painter whose work introduced new modes of representation, influenced avant-garde artistic movements of the early 20th century and formed the bridge between late 19th-century Impressionism and early 20th-century Cubism.
|
||||
While his early works were influenced by Romanticism—such as the murals in the Jas de Bouffan country house—and Realism, Cézanne arrived at a new pictorial language through intense examination of Impressionist forms of expression. He altered conventional approaches to perspective and broke established rules of academic art by emphasizing the underlying structure of objects in a composition and the formal qualities of art. Cézanne strived for a renewal of traditional design methods on the basis of the impressionistic colour space and colour modulation principles.
|
||||
Cézanne's often repetitive, exploratory brushstrokes are highly characteristic and recognizable. He used planes of colour and small brushstrokes that build up to form complex fields. The paintings convey Cézanne's intense study of his subjects.
|
||||
His paintings initially provoked incomprehension and ridicule in contemporary art criticism. Until the late 1890s, it was mainly fellow artists such as Camille Pissarro and the art dealer and gallery owner Ambroise Vollard who discovered Cézanne's work. These were some of his first buyers. In 1895, Vollard opened the first solo exhibition in his Paris gallery, which led to a broader examination of Cézanne's work. Both Henri Matisse and Pablo Picasso are said to have remarked that Cézanne ""is the father of us all"".","Paul Cézanne",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cubism, Impressionism, Post-Impressionism, Romanticism, Unknown"", ""movement"": ""Post-Impressionism"", ""last_year"": 1970, ""birth_year"": 1839, ""death_year"": 1906, ""first_year"": 1855, ""pupils_raw"": null, ""birth_place"": ""Aix-en-Provence"", ""citizenship"": ""France"", ""death_place"": ""Aix-en-Provence"", ""nationality"": ""French"", ""occupations"": ""painter, printmaker, drawer, lithographer"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Cubism:52},{Impressionism:105},{Post-Impressionism:317},{Romanticism:107},{Unknown:7}"", ""art500k_movements"": ""{Post-Impressionism:604},{Modern art:24},{Cubism:2},{Impressionism:3}"", ""influenced_by_raw"": ""Gustave Courbet,El Greco,Charles-Francois Daubigny,Nicolas Poussin,Pierre-Auguste Renoir,"", ""influenced_on_raw"": ""Pablo Picasso,Amedeo Modigliani,Jackson Pollock,Fernand Leger,Chaim Soutine,Piet Mondrian,Francis Bacon,Man Ray,Vilhelm Lundstrom,Paul Gauguin,Wassily Kandinsky,Roman Selsky,Adalbert Erdeli,Michel Kikoine,Giorgio Morandi,Józef Pankiewicz,Robert Falk,Harry Phelan Gibb,Marjorie Acker Phillips,"", ""wikiart_pictures_count"": 588}","portraits/thumbs/Paul_C_zanne_thumb.jpg"
|
||||
77,"Paul Gauguin",1848,1903,14,"portraits/Paul_Gauguin.png","Eugène Henri Paul Gauguin (; French: [øʒɛn ɑ̃ʁi pɔl ɡoɡɛ̃]; 7 June 1848 – 8 May 1903) was a French painter, sculptor, printmaker, ceramist, and writer, whose work has been primarily associated with the Post-Impressionist and Symbolist movements. He was also an influential practitioner of wood engraving and woodcuts as art forms.","Eugène Henri Paul Gauguin (; French: [øʒɛn ɑ̃ʁi pɔl ɡoɡɛ̃]; 7 June 1848 – 8 May 1903) was a French painter, sculptor, printmaker, ceramist, and writer, whose work has been primarily associated with the Post-Impressionist and Symbolist movements. He was also an influential practitioner of wood engraving and woodcuts as art forms. While only moderately successful during his lifetime, Gauguin has since been recognized for his experimental use of color and Synthetist style that were distinct from Impressionism.
|
||||
Gauguin was born in Paris in 1848, amidst the tumult of Europe's revolutionary year. In 1850, Gauguin's family settled in Peru, where he experienced a privileged childhood (until the age of six) that left a lasting impression on him. Later, financial struggles led them back to France, where Gauguin received formal education. Initially working as a stockbroker, Gauguin started painting in his spare time, his interest in art kindled by visits to galleries and exhibitions. The financial crisis of 1882 significantly impacted his brokerage career, prompting a shift to full-time painting. Gauguin's art education was largely self-taught and informal, shaped significantly by his associations with other artists rather than academic training. His entry into the art world was facilitated by his acquaintance with Camille Pissarro, a leading Impressionist. Pissarro took on a mentor role for Gauguin, introducing him to other Impressionist artists and techniques.
|
||||
He exhibited with the Impressionists in the early 1880s, but soon began developing his distinct style, characterized by a bolder use of color and less traditional subject matter. His work in Brittany and Martinique showcased his inclination towards depicting native life and landscapes. By the 1890s, Gauguin's art took a significant turn during his time in Tahiti, then a French colony, where he sought a refuge from the Western civilization. Gauguin's later years in Tahiti and the Marquesas Islands were marked by health problems and financial struggles.
|
||||
His paintings from that period, characterized by vivid colors and Symbolist themes, would prove highly successful among the European viewers for their exploration of the relationships between people, nature, and the spiritual world. Gauguin's art became popular after his death, partially from the efforts of dealer Ambroise Vollard, who organized exhibitions of his work late in his career and assisted in organizing two important posthumous exhibitions in Paris. His work was influential on the French avant-garde and many modern artists, such as Pablo Picasso and Henri Matisse, and he is well known for his relationship with Vincent and Theo van Gogh.","Paul Gauguin",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Cloisonnism, Impressionism, Japonism, Naïve Art (Primitivism), Post-Impressionism, Synthetism, Unknown"", ""movement"": ""Symbolism"", ""last_year"": 1921, ""birth_year"": 1848, ""death_year"": 1903, ""first_year"": 1875, ""pupils_raw"": null, ""birth_place"": ""Paris"", ""citizenship"": ""France"", ""death_place"": ""Atuona"", ""nationality"": ""French"", ""occupations"": ""illustrator, painter, sculptor, graphic artist, visual artist, ceramicist, printmaker, drawer, translator, artist, journalist, scenographer"", ""teachers_raw"": ""Camille Pissarro,Académie Colarossi, Paris, France,"", ""painting_school"": ""Pont-Aven School"", ""styles_extended"": ""{Cloisonnism:97},{Impressionism:153},{Japonism:6},{Naïve Art (Primitivism):1},{Post-Impressionism:244},{Synthetism:3},{Unknown:12}"", ""art500k_movements"": ""{Post-Impressionism:1},{Symbolism:1},{Primitivism:1},{Modern art:2},{Symbolism,Post-Impressionism:506}"", ""influenced_by_raw"": ""Edgar Degas,Camille Pissarro,Giotto,Raphael,Jean Auguste Dominique Ingres,Eugene Delacroix,Edouard Manet,Paul Cezanne,Alfred Sisley,"", ""influenced_on_raw"": ""Edvard Munch,Marc Chagall,Pablo Picasso,Robert Delaunay,Constantin Brancusi,Aristide Maillol,Vincent van Gogh,Arts and Crafts ,Mikhail Larionov,Victor Palmov,Emily Carr,"", ""wikiart_pictures_count"": 516}","portraits/thumbs/Paul_Gauguin_thumb.jpg"
|
||||
80,"Pierre-Auguste Renoir",1841,1919,13,"portraits/Pierre-Auguste_Renoir.jpg","Pierre-Auguste Renoir (; French: [pjɛʁ oɡyst ʁənwaʁ]; 25 February 1841 – 3 December 1919) was a leading French Impressionist artist. ""
|
||||
He was the father of the actor Pierre Renoir (1885–1952), the filmmaker Jean Renoir (1894–1979) and the ceramic artist Claude Renoir (1901–1969).","Pierre-Auguste Renoir (; French: [pjɛʁ oɡyst ʁənwaʁ]; 25 February 1841 – 3 December 1919) was a leading French Impressionist artist. In his depiction of feminine beauty, Renoir has been described as ""the final representative of a tradition which runs directly from Rubens to Watteau.""
|
||||
He was the father of the actor Pierre Renoir (1885–1952), the filmmaker Jean Renoir (1894–1979) and the ceramic artist Claude Renoir (1901–1969). He was the grandfather of the filmmaker Claude Renoir (1913–1993), son of Pierre.","Pierre-Auguste Renoir",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Impressionism, Japonism, Magic Realism, Orientalism, Realism, Unknown"", ""movement"": ""Impressionism"", ""last_year"": 1936, ""birth_year"": 1841, ""death_year"": 1919, ""first_year"": 1866, ""pupils_raw"": null, ""birth_place"": ""Limoges"", ""citizenship"": ""France"", ""death_place"": ""Cagnes-sur-Mer"", ""nationality"": null, ""occupations"": ""porcelain painter, drawer, architectural draftsperson, printmaker, graphic artist, sculptor, painter, illustrator"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Impressionism:1368},{Japonism:1},{Magic Realism:1},{Orientalism:3},{Realism:22},{Unknown:17}"", ""art500k_movements"": ""{Modern art:3},{Impressionism:2}"", ""influenced_by_raw"": null, ""influenced_on_raw"": null, ""wikiart_pictures_count"": 1412}","portraits/thumbs/Pierre-Auguste_Renoir_thumb.jpg"
|
||||
79,"Pierre Puvis de Chavannes",1824,1898,15,"portraits/Pierre_Puvis_de_Chavannes.jpg","Pierre Puvis de Chavannes (French pronunciation: [pjɛʁ pyvi də ʃavan]; 14 December 1824 – 24 October 1898) was a French painter known for his mural painting, who came to be known as ""the painter for France"". He became the co-founder and president of the Société Nationale des Beaux-Arts, and his work influenced many other artists, notably Robert Genin, and he aided medallists by designs and suggestions for their works.","Pierre Puvis de Chavannes (French pronunciation: [pjɛʁ pyvi də ʃavan]; 14 December 1824 – 24 October 1898) was a French painter known for his mural painting, who came to be known as ""the painter for France"". He became the co-founder and president of the Société Nationale des Beaux-Arts, and his work influenced many other artists, notably Robert Genin, and he aided medallists by designs and suggestions for their works. Puvis de Chavannes was a prominent painter in the early Third Republic. Émile Zola described his work as ""an art made of reason, passion, and will"".","Pierre Puvis de Chavannes",1800,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""Symbolism"", ""movement"": ""Symbolism"", ""last_year"": 1897, ""birth_year"": 1824, ""death_year"": 1898, ""first_year"": 1863, ""pupils_raw"": ""Anna Ancher,"", ""birth_place"": ""Lyon"", ""citizenship"": ""France"", ""death_place"": ""Paris"", ""nationality"": ""French"", ""occupations"": ""photographer, painter"", ""teachers_raw"": null, ""painting_school"": null, ""styles_extended"": ""{Symbolism:70}"", ""art500k_movements"": ""{Symbolism:72},{Modern art:1}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Stanisław Wyspiański,"", ""wikiart_pictures_count"": 70}","portraits/thumbs/Pierre_Puvis_de_Chavannes_thumb.jpg"
|
||||
81,"Pontormo",1494,1557,7,"portraits/Pontormo.jpg","Jacopo Carucci or Carrucci (IPA: [ˈjaːkopo ka(r)ˈruttʃi]; May 24, 1494 – January 2, 1557), usually known as Jacopo (da) Pontormo or simply Pontormo (IPA: [ponˈtormo]), was an Italian Mannerist painter and portraitist from the Florentine School. His work represents a profound stylistic shift from the calm perspectival regularity that characterized the art of the Florentine Renaissance.","Jacopo Carucci or Carrucci (IPA: [ˈjaːkopo ka(r)ˈruttʃi]; May 24, 1494 – January 2, 1557), usually known as Jacopo (da) Pontormo or simply Pontormo (IPA: [ponˈtormo]), was an Italian Mannerist painter and portraitist from the Florentine School. His work represents a profound stylistic shift from the calm perspectival regularity that characterized the art of the Florentine Renaissance. He is famous for his use of twining poses, coupled with ambiguous perspective; his figures often seem to float in an uncertain environment, unhampered by the forces of gravity.","Pontormo",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": null, ""movement"": ""{Mannerism (Late Renaissance):105}"", ""last_year"": 1523, ""birth_year"": 1494, ""death_year"": 1556, ""first_year"": 1512, ""pupils_raw"": null, ""birth_place"": ""Empoli"", ""citizenship"": ""Italy"", ""death_place"": ""Florence"", ""nationality"": ""Italian"", ""occupations"": ""['painter', 'drawer']"", ""teachers_raw"": ""Mariotto Albertinelli,"", ""painting_school"": ""Florentine School"", ""styles_extended"": null, ""art500k_movements"": null, ""influenced_by_raw"": ""male-portraits,Christianity,Old-Testament,Christianity,saints-and-apostles,Holy-Family,Virgin-and-Child,Christianity,Jesus-Christ,Christianity,saints-and-apostles,Virgin-and-Child,St.-Peter,St.-Sebastian,St.-Benedict,St.-Philip,St. Anne,Christianity,saints-and-apostles,St.-Elizabeth,Virgin-Mary,Greek-and-Roman-Mythology,female-nude,female-portraits,Christianity,saints-and-apostles,St. Luke,Christianity,saints-and-apostles,male-portraits,famous-people,male-portraits,group-portraits,Christianity,saints-and-apostles,Virgin-and-Child,"", ""influenced_on_raw"": ""Louvre, Paris, France,National Gallery, London, UK,Bode Museum, Berlin, Germany,Uffizi Gallery, Florence, Italy,"", ""wikiart_pictures_count"": null}","portraits/thumbs/Pontormo_thumb.jpg"
|
||||
82,"Raphael",1483,1520,6,"portraits/Raphael.jpg","Raffaello Sanzio da Urbino (Italian: [raffaˈɛllo ˈsantsjo da urˈbiːno]; March 28 or 6 April 1483 – 6 April 1520), now generally known in English as Raphael (UK: RAF-ay-əl, US: RAF-ee-əl, RAY-fee-, RAH-fy-EL), was an Italian painter and architect of the High Renaissance. His work is admired for its clarity of form, ease of composition, and visual achievement of the Neoplatonic ideal of human grandeur.","Raffaello Sanzio da Urbino (Italian: [raffaˈɛllo ˈsantsjo da urˈbiːno]; March 28 or 6 April 1483 – 6 April 1520), now generally known in English as Raphael (UK: RAF-ay-əl, US: RAF-ee-əl, RAY-fee-, RAH-fy-EL), was an Italian painter and architect of the High Renaissance. His work is admired for its clarity of form, ease of composition, and visual achievement of the Neoplatonic ideal of human grandeur. Together with Leonardo da Vinci and Michelangelo, he forms the traditional trinity of great masters of that period.
|
||||
His father Giovanni Santi was court painter to the ruler of the small but highly cultured city of Urbino. He died when Raphael was eleven, and Raphael seems to have played a role in managing the family workshop from this point. He probably trained in the workshop of Pietro Perugino, and was described as a fully trained ""master"" by 1500. He worked in or for several cities in north Italy until in 1508 he moved to Rome at the invitation of Pope Julius II, to work on the Apostolic Palace at the Vatican. He was given a series of important commissions there and elsewhere in the city, and began to work as an architect. He was still at the height of his powers at his death in 1520.
|
||||
Raphael was enormously productive, running an unusually large workshop and, despite his early death at 37, leaving a large body of work. His career falls naturally into three phases and three styles, first described by Giorgio Vasari: his early years in Umbria, then a period of about four years (1504–1508) absorbing the artistic traditions of Florence, followed by his last hectic and triumphant twelve years in Rome, working for two popes and their close associates. Many of his works are found in the Vatican Palace, where the frescoed Raphael Rooms were the central, and the largest, work of his career. The best known work is The School of Athens in the Vatican Stanza della Segnatura. After his early years in Rome, much of his work was executed by his workshop from his drawings, with considerable loss of quality. He was extremely influential in his lifetime, though outside Rome his work was mostly known from his collaborative printmaking.
|
||||
|
||||
After his death, the influence of his great rival Michelangelo exceeded his until the 18th and 19th centuries, when Raphael's more serene and harmonious qualities were again regarded as the highest models. Thanks to the influence of art historian Johann Joachim Winckelmann, his work became a formative influence on Neoclassical painting, but his techniques would later be explicitly and emphatically rejected by groups such as the Pre-Raphaelite Brotherhood.","Raphael",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""High Renaissance, Unknown"", ""movement"": ""High Renaissance"", ""last_year"": 1520, ""birth_year"": 1483, ""death_year"": 1520, ""first_year"": 1499, ""pupils_raw"": null, ""birth_place"": ""Urbino"", ""citizenship"": ""Holy Roman Empire"", ""death_place"": ""Rome"", ""nationality"": ""Italian"", ""occupations"": ""fresco painter, drawer, architectural draftsperson, designer, sculptor, painter, court painter, architect, university teacher, illustrator"", ""teachers_raw"": ""Pietro Perugino,"", ""painting_school"": ""Umbrian school"", ""styles_extended"": ""{High Renaissance:130},{Unknown:54}"", ""art500k_movements"": ""{High Renaissance:186},{Italian Renaissance:1},{Renaissance:1}"", ""influenced_by_raw"": ""Albrecht Durer,Paolo Uccello,Luca Signorelli,Michelangelo,Hans Memling,"", ""influenced_on_raw"": ""Giorgio de Chirico,Annibale Carracci,Jacopo Bassano,Paul Gauguin,Francisco Pacheco,Titian,Nicolas Poussin,Edgar Degas,Pierre-Auguste Renoir,Anton Raphael Mengs,Nazarenes,Düsseldorf School of Painting,"", ""wikiart_pictures_count"": 184}","portraits/thumbs/Raphael_thumb.jpg"
|
||||
89,"Simone Martini",1284,1344,3,"portraits/Simone_Martini.jpg","Simone Martini (c. 1284 – July 1344) was an Italian painter born in Siena.","Simone Martini (c. 1284 – July 1344) was an Italian painter born in Siena.
|
||||
He was a major figure in the development of early Italian painting and greatly influenced the development of the International Gothic style.
|
||||
It is thought that Martini was a pupil of Duccio di Buoninsegna, the leading Sienese painter of his time. According to late Renaissance art biographer Giorgio Vasari, Simone was instead a pupil of Giotto di Bondone, with whom he went to Rome to paint at the Old St. Peter's Basilica, Giotto also executing a mosaic there. Martini's brother-in-law was the artist Lippo Memmi. Very little documentation of Simone's life survives, and many attributions are debated by art historians. According to E. H. Gombrich, he was a friend of Petrarch and had painted a portrait of Laura.","Simone Martini",1200,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""International Gothic, Unknown"", ""movement"": ""Gothic Art"", ""last_year"": 1342, ""birth_year"": 1284, ""death_year"": null, ""first_year"": 1319, ""pupils_raw"": null, ""birth_place"": ""Siena"", ""citizenship"": ""Italy"", ""death_place"": null, ""nationality"": ""Italian"", ""occupations"": ""computer scientist, university teacher"", ""teachers_raw"": null, ""painting_school"": ""Sienese School"", ""styles_extended"": ""{International Gothic:9},{Unknown:1}"", ""art500k_movements"": ""{Gothic Art:10}"", ""influenced_by_raw"": null, ""influenced_on_raw"": ""Ambrogio Lorenzetti,Pietro Lorenzetti,"", ""wikiart_pictures_count"": 10}","portraits/thumbs/Simone_Martini_thumb.jpg"
|
||||
92,"Titian",1488,1576,6,"portraits/Titian.jpg","Tiziano Vecellio (Italian: [titˈtsjaːno veˈtʃɛlljo]; c. 1488/1490 – 27 August 1576), Latinized as Titianus, hence known in English as Titian ( TISH-ən), was an Italian Renaissance painter.","Tiziano Vecellio (Italian: [titˈtsjaːno veˈtʃɛlljo]; c. 1488/1490 – 27 August 1576), Latinized as Titianus, hence known in English as Titian ( TISH-ən), was an Italian Renaissance painter. The most important artist of Renaissance Venetian painting, he was born in Pieve di Cadore, near Belluno.
|
||||
Titian was one of the most versatile of Italian painters, equally adept with portraits, landscape backgrounds, and mythological and religious subjects. His painting methods, particularly in the application and use of colour, exerted a profound influence not only on painters of the late Italian Renaissance, but on future generations of Western artists.
|
||||
His career was successful from the start, and he became sought after by patrons, initially from Venice and its possessions, then joined by the north Italian princes, and finally the Habsburgs and the papacy. Along with Giorgione, he is considered a founder of the Venetian school of Italian Renaissance painting. In 1590, the painter and art theorist Giovanni Paolo Lomazzo described Titian as ""the sun amidst small stars not only among the Italians but all the painters of the world"".
|
||||
During his long life, Titian's artistic manner changed drastically, but he retained a lifelong interest in colour. Although his mature works may not contain the vivid, luminous tints of his early pieces, they are remarkable and original in their loose brushwork and subtlety of tone.","Titian",1400,False,False,"{""gender"": ""male"", ""source"": ""PainterPalette.csv"", ""styles"": ""High Renaissance, Mannerism (Late Renaissance), Unknown"", ""movement"": ""High Renaissance"", ""last_year"": 1576, ""birth_year"": 1490, ""death_year"": 1576, ""first_year"": 1500, ""pupils_raw"": ""El Greco,Annibale Carracci,"", ""birth_place"": ""Pieve di Cadore"", ""citizenship"": ""Republic of Venice"", ""death_place"": ""Venice"", ""nationality"": ""Italian"", ""occupations"": ""painter, printmaker, architectural draftsperson, drawer"", ""teachers_raw"": ""Giovanni Bellini,"", ""painting_school"": ""Venetian School"", ""styles_extended"": ""{High Renaissance:109},{Mannerism (Late Renaissance):92},{Unknown:65}"", ""art500k_movements"": ""{Italian Renaissance:1},{Venetian school:1},{Renaissance:1},{High Renaissance,Mannerism (Late Renaissance):254}"", ""influenced_by_raw"": ""Albrecht Durer,Giorgione,Raphael,Michelangelo,"", ""influenced_on_raw"": ""Jacopo Bassano,Peter Paul Rubens,Diego Velazquez,Caravaggio,Rembrandt,Nicolas Poussin,Edgar Degas,Anton Raphael Mengs,Anthony van Dyck,John Singer Sargent,William Dobson,Claudio Coello,"", ""wikiart_pictures_count"": 266}","portraits/thumbs/Titian_thumb.jpg"
|
||||
|
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
Binary file not shown.
@@ -1,12 +1,16 @@
|
||||
# Art Gallery
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit (**Checked** / **Fix it**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||
| [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables |
|
||||
| [Documentation/API.md](Documentation/API.md) | REST endpoints |
|
||||
@@ -19,7 +23,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and set database credentials.
|
||||
1. Copy `.env.example` to `.env` and set database credentials (`DB_NAME=gallery_dev` after [one-time split](Documentation/environments.md)). Set `SESSION_SECRET`, `CURATOR_USERNAME`, and `CURATOR_PASSWORD` for curator login (see [setup.md](Documentation/setup.md)).
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
@@ -30,35 +34,60 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
||||
3. Apply database schema (requires PostgreSQL superuser for first-time setup):
|
||||
|
||||
```bash
|
||||
npm run migrate
|
||||
npm run seed
|
||||
npm run dev:migrate
|
||||
npm run dev:seed
|
||||
```
|
||||
|
||||
4. Enrich the catalog (recommended after seed):
|
||||
|
||||
```bash
|
||||
npm run fetch-artist-bios # Wikipedia biographies for all artists
|
||||
npm run expand-catalog # add famous works for artists with thin catalogs
|
||||
npm run update-influences # art-history lineage links between paintings
|
||||
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode
|
||||
npm run fetch-images -- --limit=50 # random batch of missing images (10s per work)
|
||||
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
||||
npm run dev:sync-image-paths # import paintings from data/images/paintings/ when present
|
||||
npm run dev:fetch-artist-images # link or download artist portraits
|
||||
npm run dev:fetch-artist-bios # Wikipedia biographies for all artists
|
||||
npm run dev:expand-catalog # add famous works for artists with thin catalogs
|
||||
npm run dev:update-influences # art-history lineage links (detail panels + 3D hall)
|
||||
npm run dev:migrate:artist-palette # optional: JSONB column for PainterPalette enrichment
|
||||
npm run dev:import-painter-palette # optional: metadata + influence links from Inputs/PainterPalette.csv
|
||||
npm run dev:migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings)
|
||||
npm run dev:migrate:artist-checkup-flags # same flags for artist portraits (bio debug)
|
||||
npm run dev:migrate:painting-annotations # art-history notes on painting detail
|
||||
npm run dev:update-painting-annotations # load curated notes (+ optional --wikipedia)
|
||||
npm run dev:fetch-images -- --limit=50 # random batch of missing images (10s per work)
|
||||
npm run dev:fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
||||
```
|
||||
|
||||
5. Build the client and start the server:
|
||||
5. **Development (public URL):**
|
||||
|
||||
```bash
|
||||
cd client && npm run build && cd ..
|
||||
npm run server
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Open http://localhost:3001
|
||||
Open https://devgallery.mysuperlab.netcraze.pro (or http://localhost:5173 locally).
|
||||
|
||||
**Production:** https://gallery.mysuperlab.netcraze.pro — see [Documentation/environments.md](Documentation/environments.md) and [infra/docker/DEPLOY-truenas.md](infra/docker/DEPLOY-truenas.md).
|
||||
|
||||
## Deployed URLs
|
||||
|
||||
| Environment | URL | Host |
|
||||
|-------------|-----|------|
|
||||
| Development | https://devgallery.mysuperlab.netcraze.pro | Dev PC `:5173` (Vite) + `:3451` (API) |
|
||||
| Production | https://gallery.mysuperlab.netcraze.pro | TrueNAS Docker `:5173` |
|
||||
|
||||
Operator guide: [Documentation/environments.md](Documentation/environments.md).
|
||||
|
||||
## Development
|
||||
|
||||
Run API and Vite dev server separately:
|
||||
**Workflow:** All changes are made and tested on **dev** (`gallery_dev`, https://devgallery.mysuperlab.netcraze.pro). Production is updated on a **scheduled release** (~weekly): promote DB/images, build/push the Docker image, restart TrueNAS. See [Documentation/environments.md](Documentation/environments.md#development-first-workflow-default).
|
||||
|
||||
**Public dev (Keenetic):**
|
||||
|
||||
```bash
|
||||
npm run dev:server # http://localhost:3001
|
||||
npm run dev:web # Vite :5173, API :3451
|
||||
```
|
||||
|
||||
**Local HMR:**
|
||||
|
||||
```bash
|
||||
npm run dev:server # API — PORT from .env
|
||||
npm run dev:client # http://localhost:5173 (proxies /api and /images)
|
||||
```
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
Generated
+76
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+990
-32
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
.artist-bio {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e8d5b5;
|
||||
@@ -58,6 +59,19 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bio-portrait-checked img {
|
||||
border-color: #ffd700;
|
||||
box-shadow: 0 8px 28px rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.bio-portrait-empty {
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
border: 4px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bio-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,69 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Artist } from '../types';
|
||||
import { imageUrl } from '../api/client';
|
||||
import {
|
||||
api,
|
||||
debugImageProxyUrl,
|
||||
portraitUrl,
|
||||
type DebugImageSearchResult,
|
||||
type DebugImageSearchResultItem,
|
||||
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;
|
||||
onArtistPortraitFixed?: (
|
||||
artistId: number,
|
||||
fixResult: FixArtistPortraitResult
|
||||
) => void | Promise<void>;
|
||||
onArtistCheckupFlagsUpdated?: (
|
||||
artistId: number,
|
||||
flags: { checked: boolean; fixed: boolean }
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
export default function ArtistBio({
|
||||
artist,
|
||||
debugMode = false,
|
||||
debugShowMore = false,
|
||||
canCheckup = true,
|
||||
portraitRevision = 0,
|
||||
onBack,
|
||||
onEnterGallery,
|
||||
onArtistPortraitFixed,
|
||||
onArtistCheckupFlagsUpdated,
|
||||
}: Props) {
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
const [debugLoading, setDebugLoading] = useState(false);
|
||||
const [debugError, setDebugError] = useState<string | null>(null);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const [markingChecked, setMarkingChecked] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [moreLoading, setMoreLoading] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getArtistDebugPortraitSearchMore>> | null>(null);
|
||||
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 portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
|
||||
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
|
||||
? `${artist.birth_year} – ${artist.death_year}`
|
||||
@@ -16,8 +71,178 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
? `b. ${artist.birth_year}`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode) {
|
||||
setDebugSearch(null);
|
||||
setDebugError(null);
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
setDebugError(null);
|
||||
setDebugSearch(null);
|
||||
|
||||
api.getArtistDebugPortraitSearch(artist.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDebugSearch(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDebugError('Portrait image search failed.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDebugLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, uploading, artist.id, artist.name]);
|
||||
|
||||
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
|
||||
if (onArtistPortraitFixed) {
|
||||
await onArtistPortraitFixed(artist.id, fixResult);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFixFromSearch = async (
|
||||
imageUrl: string,
|
||||
context: { searchUrl: string; source: string; thumbUrl?: string }
|
||||
) => {
|
||||
const fixResult = await api.fixArtistPortrait(artist.id, imageUrl, context);
|
||||
await applyPortraitUpdate(fixResult);
|
||||
setDebugSearch((prev) =>
|
||||
prev
|
||||
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
const handleFixPortrait = async () => {
|
||||
if (!debugSearch?.imageUrl || fixing) return;
|
||||
setFixing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
await applyFixFromSearch(debugSearch.imageUrl, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
thumbUrl: debugSearch.thumbUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not replace portrait.');
|
||||
} finally {
|
||||
setFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMore = useCallback(async () => {
|
||||
setMoreOpen(true);
|
||||
setMoreLoading(true);
|
||||
setMoreError(null);
|
||||
setMoreResults(null);
|
||||
try {
|
||||
const results = await api.getArtistDebugPortraitSearchMore(artist.id);
|
||||
setMoreResults(results);
|
||||
} catch {
|
||||
setMoreError('Could not load search results.');
|
||||
} finally {
|
||||
setMoreLoading(false);
|
||||
}
|
||||
}, [artist.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode || !debugShowMore) return;
|
||||
void handleOpenMore();
|
||||
}, [debugMode, debugShowMore, artist.id, handleOpenMore]);
|
||||
|
||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||
if (fixing || applyingUrl) return;
|
||||
setApplyingUrl(item.imageUrl);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
|
||||
await applyFixFromSearch(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
thumbUrl: item.thumbUrl,
|
||||
});
|
||||
setMoreOpen(false);
|
||||
} catch (err) {
|
||||
setMoreError(err instanceof Error ? err.message : 'Could not replace portrait.');
|
||||
} finally {
|
||||
setApplyingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkChecked = async () => {
|
||||
if (artist.checkup_checked || markingChecked) return;
|
||||
setMarkingChecked(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const updated = await api.updateArtistCheckupFlags(artist.id, { checked: true });
|
||||
await onArtistCheckupFlagsUpdated?.(artist.id, updated);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
|
||||
} finally {
|
||||
setMarkingChecked(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPortrait = async () => {
|
||||
if (clearing || fixing || uploading) return;
|
||||
setClearing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.clearArtistPortrait(artist.id);
|
||||
await applyPortraitUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not clear portrait.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -25,14 +250,18 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
</header>
|
||||
|
||||
<div className="bio-content">
|
||||
<div className="bio-portrait">
|
||||
<img
|
||||
src={imageUrl(artist.portrait_path)}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
|
||||
>
|
||||
{showPortrait && portraitSrc ? (
|
||||
<img
|
||||
src={portraitSrc}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bio-text">
|
||||
@@ -66,6 +295,91 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<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 && !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, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
})}
|
||||
alt={`Search result for ${debugSearch.query}`}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
{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 || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-clear-btn"
|
||||
onClick={handleClearPortrait}
|
||||
disabled={clearing || fixing || uploading || portraitCleared}
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose portrait"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
error={moreError}
|
||||
applyingUrl={applyingUrl}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
.artist-filter-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.artist-filter-modal {
|
||||
width: min(100%, 720px);
|
||||
max-height: min(90vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.artist-filter-header {
|
||||
padding: 20px 24px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artist-filter-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 22px;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.artist-filter-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
}
|
||||
|
||||
.artist-filter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 24px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artist-filter-toggle-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 220, 160, 0.9);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.artist-filter-toggle-all:hover {
|
||||
color: #ffe8c0;
|
||||
}
|
||||
|
||||
.artist-filter-count {
|
||||
font-size: 12px;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
}
|
||||
|
||||
.artist-filter-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 4px 24px 16px;
|
||||
}
|
||||
|
||||
.artist-filter-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.2);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s, background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.artist-filter-card:hover {
|
||||
border-color: rgba(255, 220, 160, 0.45);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.artist-filter-card-selected {
|
||||
border-color: rgba(255, 220, 160, 0.55);
|
||||
background: rgba(255, 220, 160, 0.08);
|
||||
}
|
||||
|
||||
.artist-filter-card:not(.artist-filter-card-selected) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.artist-filter-portrait-wrap {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.artist-filter-portrait {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(201, 169, 110, 0.4);
|
||||
}
|
||||
|
||||
.artist-filter-portrait-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
color: rgba(232, 213, 181, 0.6);
|
||||
font-size: 20px;
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.artist-filter-check {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 220, 160, 0.95);
|
||||
color: #1a1a2e;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.artist-filter-card:not(.artist-filter-card-selected) .artist-filter-check {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.artist-filter-name {
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e8d5b5;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.artist-filter-years {
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
}
|
||||
|
||||
.artist-filter-paintings {
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
}
|
||||
|
||||
.artist-filter-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artist-filter-cancel,
|
||||
.artist-filter-proceed {
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.artist-filter-cancel {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: transparent;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.artist-filter-cancel:hover {
|
||||
background: rgba(201, 169, 110, 0.1);
|
||||
}
|
||||
|
||||
.artist-filter-proceed {
|
||||
border: none;
|
||||
background: linear-gradient(180deg, #c9a96e 0%, #a88b4a 100%);
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.artist-filter-proceed:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.artist-filter-proceed:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.artist-filter-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.artist-filter-header,
|
||||
.artist-filter-toolbar,
|
||||
.artist-filter-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ArtistSummary } from '../types';
|
||||
import { portraitThumbUrl, portraitUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import './ArtistFilterModal.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
movementName: string;
|
||||
artists: ArtistSummary[];
|
||||
portraitRevisions?: Record<number, number>;
|
||||
onProceed: (selectedIds: Set<number>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatLifespan(birth: number | null, death: number | null): string {
|
||||
const b = birth != null ? String(birth) : '?';
|
||||
const d = death != null ? String(death) : '?';
|
||||
return `${b}–${d}`;
|
||||
}
|
||||
|
||||
function paintingCountLabel(count: number): string {
|
||||
return count === 1 ? '1 painting' : `${count} paintings`;
|
||||
}
|
||||
|
||||
function ArtistFilterPortrait({
|
||||
artist,
|
||||
revision,
|
||||
}: {
|
||||
artist: ArtistSummary;
|
||||
revision?: number;
|
||||
}) {
|
||||
const thumbSrc =
|
||||
artist.portrait_thumb_path || artist.portrait_path
|
||||
? portraitThumbUrl(artist, revision)
|
||||
: null;
|
||||
const fullSrc = artist.portrait_path
|
||||
? portraitUrl(artist.portrait_path, revision ?? artist.portrait_cache_key)
|
||||
: null;
|
||||
const [srcIndex, setSrcIndex] = useState(0);
|
||||
const candidates = [thumbSrc, fullSrc].filter((s, i, arr): s is string => Boolean(s) && arr.indexOf(s) === i);
|
||||
const requested = candidates[srcIndex] ?? null;
|
||||
const queuedSrc = useQueuedImageSrc(requested);
|
||||
|
||||
useEffect(() => {
|
||||
setSrcIndex(0);
|
||||
}, [thumbSrc, fullSrc]);
|
||||
|
||||
if (!queuedSrc) {
|
||||
return (
|
||||
<span className="artist-filter-portrait artist-filter-portrait-placeholder" aria-hidden>
|
||||
?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={queuedSrc}
|
||||
alt=""
|
||||
className="artist-filter-portrait"
|
||||
onError={() => {
|
||||
setSrcIndex((i) => (i + 1 < candidates.length ? i + 1 : candidates.length));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistFilterModal({
|
||||
open,
|
||||
movementName,
|
||||
artists,
|
||||
portraitRevisions,
|
||||
onProceed,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const allIds = useMemo(() => new Set(artists.map((a) => a.id)), [artists]);
|
||||
const [selected, setSelected] = useState<Set<number>>(() => new Set(allIds));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(new Set(artists.map((a) => a.id)));
|
||||
}
|
||||
}, [open, artists]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const allSelected = selected.size === artists.length && artists.length > 0;
|
||||
const noneSelected = selected.size === 0;
|
||||
|
||||
const toggleArtist = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected(allSelected ? new Set() : new Set(allIds));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="artist-filter-backdrop" onMouseDown={onClose}>
|
||||
<div
|
||||
className="artist-filter-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="artist-filter-title"
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="artist-filter-header">
|
||||
<h2 id="artist-filter-title">{movementName}</h2>
|
||||
<p className="artist-filter-subtitle">Choose artists to include in the gallery hall</p>
|
||||
</header>
|
||||
|
||||
<div className="artist-filter-toolbar">
|
||||
<button type="button" className="artist-filter-toggle-all" onClick={toggleAll}>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
<span className="artist-filter-count">
|
||||
{selected.size} of {artists.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="artist-filter-grid">
|
||||
{artists.map((artist) => {
|
||||
const isSelected = selected.has(artist.id);
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
type="button"
|
||||
className={`artist-filter-card${isSelected ? ' artist-filter-card-selected' : ''}`}
|
||||
onClick={() => toggleArtist(artist.id)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="artist-filter-portrait-wrap">
|
||||
<ArtistFilterPortrait
|
||||
artist={artist}
|
||||
revision={portraitRevisions?.[artist.id]}
|
||||
/>
|
||||
<span className="artist-filter-check" aria-hidden>
|
||||
{isSelected ? '✓' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="artist-filter-name">{artist.name}</span>
|
||||
<span className="artist-filter-years">{formatLifespan(artist.birth_year, artist.death_year)}</span>
|
||||
<span className="artist-filter-paintings">{paintingCountLabel(artist.painting_count)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="artist-filter-footer">
|
||||
<button type="button" className="artist-filter-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="artist-filter-proceed"
|
||||
disabled={noneSelected}
|
||||
onClick={() => onProceed(new Set(selected))}
|
||||
>
|
||||
Enter gallery
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.curator-login-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;
|
||||
}
|
||||
|
||||
.curator-login-modal {
|
||||
width: min(100%, 360px);
|
||||
padding: 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);
|
||||
}
|
||||
|
||||
.curator-login-modal h2 {
|
||||
margin: 0 0 8px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 20px;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-hint {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.curator-login-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.curator-login-field span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.curator-login-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e8d5b5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.curator-login-field input:focus {
|
||||
outline: 2px solid rgba(255, 220, 160, 0.45);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.curator-login-error {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #f0a0a0;
|
||||
}
|
||||
|
||||
.curator-login-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.curator-login-cancel,
|
||||
.curator-login-submit {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.curator-login-cancel {
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
background: transparent;
|
||||
color: rgba(201, 169, 110, 0.8);
|
||||
}
|
||||
|
||||
.curator-login-submit {
|
||||
border: 1px solid rgba(255, 220, 160, 0.45);
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-submit:disabled,
|
||||
.curator-login-cancel:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.curator-login-gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
}
|
||||
|
||||
.curator-login-gate h2 {
|
||||
margin: 0;
|
||||
font-family: 'Georgia', serif;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-login-gate p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.curator-login-gate-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CuratorLoginModal.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onLogin(username.trim(), password);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loginFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="curator-login-backdrop" onMouseDown={onClose}>
|
||||
<div
|
||||
className="curator-login-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="curator-login-title"
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>{t('username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="curator-login-field">
|
||||
<span>{t('password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="curator-login-error">{error}</p>}
|
||||
<div className="curator-login-actions">
|
||||
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
|
||||
{tc('cancel')}
|
||||
</button>
|
||||
<button type="submit" className="curator-login-submit" disabled={submitting}>
|
||||
{submitting ? t('saving') : t('signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
.debug-search-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.debug-search-modal {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(88vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(232, 160, 64, 0.45);
|
||||
background: rgba(15, 15, 26, 0.98);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.debug-search-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.debug-search-modal-header h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e8a040;
|
||||
}
|
||||
|
||||
.debug-search-modal-query {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.debug-search-modal-close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.4);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e8d5b5;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-search-modal-close:hover {
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
}
|
||||
|
||||
.debug-search-modal-hint,
|
||||
.debug-search-modal-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
}
|
||||
|
||||
.debug-search-modal-error {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.debug-search-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
max-height: min(68vh, 720px);
|
||||
}
|
||||
|
||||
.debug-search-modal-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border: 2px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
background: #2a1f15;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-media {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background: #2a1f15;
|
||||
}
|
||||
|
||||
.debug-search-modal-item:hover:not(:disabled) {
|
||||
border-color: #e8a040;
|
||||
box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35);
|
||||
}
|
||||
|
||||
.debug-search-modal-item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-busy {
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.debug-search-modal-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-resolution {
|
||||
display: block;
|
||||
padding: 5px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: rgba(232, 213, 181, 0.9);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.2);
|
||||
}
|
||||
|
||||
.debug-search-modal-item-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-busy-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
|
||||
import './DebugSearchResultsModal.css';
|
||||
|
||||
function formatResolution(width?: number, height?: number): string | null {
|
||||
if (!width || !height || width <= 0 || height <= 0) return null;
|
||||
return `${width} × ${height}`;
|
||||
}
|
||||
|
||||
function ResultResolution({
|
||||
item,
|
||||
searchUrl,
|
||||
}: {
|
||||
item: DebugImageSearchResultItem;
|
||||
searchUrl: string;
|
||||
}) {
|
||||
const initial = formatResolution(item.width, item.height);
|
||||
const [label, setLabel] = useState<string | null>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
setLabel(initial);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
const text = formatResolution(img.naturalWidth, img.naturalHeight);
|
||||
setLabel(text ?? '—');
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (!cancelled) setLabel('—');
|
||||
};
|
||||
img.src = debugImageProxyUrl(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [item.imageUrl, item.source, item.width, item.height, searchUrl, initial]);
|
||||
|
||||
return (
|
||||
<span className="debug-search-modal-item-resolution" aria-hidden="true">
|
||||
{label ?? '…'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
data: DebugImageSearchManyResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
applyingUrl: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (item: DebugImageSearchResultItem) => void;
|
||||
}
|
||||
|
||||
export default function DebugSearchResultsModal({
|
||||
open,
|
||||
title,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
applyingUrl,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="debug-search-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="debug-search-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="debug-search-modal-header">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{data?.query && <p className="debug-search-modal-query">{data.query}</p>}
|
||||
</div>
|
||||
<button type="button" className="debug-search-modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading && <p className="debug-search-modal-status">Loading results…</p>}
|
||||
{error && <p className="debug-search-modal-error">{error}</p>}
|
||||
|
||||
{!loading && !error && data && data.results.length === 0 && (
|
||||
<p className="debug-search-modal-status">No images found.</p>
|
||||
)}
|
||||
|
||||
{!loading && data && data.results.length > 0 && (
|
||||
<>
|
||||
<p className="debug-search-modal-hint">Click an image to replace the current one.</p>
|
||||
<div className="debug-search-modal-grid">
|
||||
{data.results.map((item, index) => {
|
||||
const busy = applyingUrl === item.imageUrl;
|
||||
return (
|
||||
<button
|
||||
key={`${item.imageUrl}-${index}`}
|
||||
type="button"
|
||||
className={`debug-search-modal-item${busy ? ' debug-search-modal-item-busy' : ''}`}
|
||||
disabled={!!applyingUrl}
|
||||
onClick={() => onSelect(item)}
|
||||
title="Use this image"
|
||||
>
|
||||
<span className="debug-search-modal-item-media">
|
||||
<img
|
||||
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
|
||||
searchUrl: data.searchUrl,
|
||||
source: item.source,
|
||||
})}
|
||||
alt={`Result ${index + 1}`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="debug-search-modal-item-index">{index + 1}</span>
|
||||
{busy && <span className="debug-search-modal-item-busy-label">Saving…</span>}
|
||||
</span>
|
||||
<ResultResolution item={item} searchUrl={data.searchUrl} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.gallery-loading-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
color: rgba(232, 213, 181, 0.92);
|
||||
font-family: Georgia, serif;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-loading-marker p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(201, 169, 110, 0.22);
|
||||
border-top-color: #c9a96e;
|
||||
animation: gallery-loading-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(10, 10, 20, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-banner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 12;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 26, 0.88);
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-banner .gallery-loading-marker-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import './GalleryLoadingMarker.css';
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
/** Full-area overlay (dims background). */
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
className={`gallery-loading-marker${modeClass}${className ? ` ${className}` : ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="gallery-loading-marker-spinner" aria-hidden />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
|
||||
interface Props {
|
||||
windows: GalleryWindowSpec[];
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
trimColor: string;
|
||||
}
|
||||
|
||||
function windowWorldPosition(
|
||||
spec: GalleryWindowSpec,
|
||||
halfW: number,
|
||||
halfD: number
|
||||
): { position: [number, number, number]; rotation: [number, number, number] } {
|
||||
const inset = 0.1;
|
||||
switch (spec.wall) {
|
||||
case 'back':
|
||||
return {
|
||||
position: [spec.x, spec.y, -halfD + inset],
|
||||
rotation: [0, 0, 0],
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
position: [-halfW + inset, spec.y, spec.x],
|
||||
rotation: [0, Math.PI / 2, 0],
|
||||
};
|
||||
case 'right':
|
||||
return {
|
||||
position: [halfW - inset, spec.y, spec.x],
|
||||
rotation: [0, -Math.PI / 2, 0],
|
||||
};
|
||||
case 'ceiling':
|
||||
return {
|
||||
position: [spec.x, WALL_HEIGHT - 0.06, spec.x === 0 ? 0 : spec.x],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
};
|
||||
default:
|
||||
return { position: [0, spec.y, -halfD + inset], rotation: [0, 0, 0] };
|
||||
}
|
||||
}
|
||||
|
||||
function WindowFrame({
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
trimColor,
|
||||
}: {
|
||||
style: GalleryWindowStyle;
|
||||
width: number;
|
||||
height: number;
|
||||
trimColor: string;
|
||||
}) {
|
||||
const frameW = 0.08;
|
||||
const depth = 0.12;
|
||||
|
||||
const arch =
|
||||
style === 'roman-arch' || style === 'gothic-lancet' || style === 'art-nouveau';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Outer frame */}
|
||||
<mesh position={[0, 0, -depth / 2]}>
|
||||
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
||||
</mesh>
|
||||
|
||||
{arch && style === 'gothic-lancet' && (
|
||||
<>
|
||||
{/* Two-centred arch head built from short chords */}
|
||||
{Array.from({ length: 14 }, (_, i) => {
|
||||
const segs = 14;
|
||||
const rise = width * 0.62;
|
||||
const pt = (t: number): [number, number] => {
|
||||
const x = (t - 0.5) * (width + frameW * 2);
|
||||
const y = Math.pow(Math.cos((t - 0.5) * Math.PI), 0.72) * rise;
|
||||
return [x, y];
|
||||
};
|
||||
const [x0, y0] = pt(i / segs);
|
||||
const [x1, y1] = pt((i + 1) / segs);
|
||||
const len = Math.hypot(x1 - x0, y1 - y0);
|
||||
return (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(x0 + x1) / 2, height / 2 + (y0 + y1) / 2, -depth / 2 + 0.02]}
|
||||
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
|
||||
>
|
||||
<boxGeometry args={[len * 1.15, frameW * 1.4, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{/* Glazed spandrel filling the arch head */}
|
||||
<mesh position={[0, height / 2 + width * 0.16, 0.01]}>
|
||||
<planeGeometry args={[width * 0.72, width * 0.5]} />
|
||||
<meshStandardMaterial
|
||||
color="#9fc4ef"
|
||||
emissive="#9fc4ef"
|
||||
emissiveIntensity={0.6}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Mullions and transoms */}
|
||||
{[-width / 4, width / 4].map((ox) => (
|
||||
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.045, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
))}
|
||||
{[height / 3, -height / 6].map((oy) => (
|
||||
<mesh key={oy} position={[0, oy, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[width, 0.035, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{style === 'roman-arch' && (
|
||||
<mesh position={[0, height / 2 + 0.05, -depth / 2 + 0.02]} rotation={[0, 0, Math.PI]}>
|
||||
<sphereGeometry args={[width / 2, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{style === 'factory' && (
|
||||
<>
|
||||
{[-width / 3, 0, width / 3].map((ox) => (
|
||||
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.02]}>
|
||||
<boxGeometry args={[0.06, height, depth + 0.02]} />
|
||||
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
{[height / 4, -height / 4].map((oy) => (
|
||||
<mesh key={oy} position={[0, oy, -depth / 2 + 0.02]}>
|
||||
<boxGeometry args={[width, 0.06, depth + 0.02]} />
|
||||
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{style === 'glass-block' &&
|
||||
Array.from({ length: 4 }, (_, row) =>
|
||||
Array.from({ length: 3 }, (_, col) => (
|
||||
<mesh
|
||||
key={`${row}-${col}`}
|
||||
position={[
|
||||
-width / 3 + col * (width / 3),
|
||||
-height / 4 + row * (height / 4),
|
||||
-depth / 2 + 0.03,
|
||||
]}
|
||||
>
|
||||
<boxGeometry args={[width / 3 - 0.06, height / 4 - 0.06, 0.04]} />
|
||||
<meshStandardMaterial
|
||||
color="#d0e8f8"
|
||||
roughness={0.1}
|
||||
metalness={0.05}
|
||||
transparent
|
||||
opacity={0.75}
|
||||
/>
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{style === 'sash' && (
|
||||
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.05, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.3} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{style === 'baroque-pair' && (
|
||||
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.06, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleWindow({
|
||||
spec,
|
||||
trimColor,
|
||||
}: {
|
||||
spec: GalleryWindowSpec;
|
||||
trimColor: string;
|
||||
}) {
|
||||
return (
|
||||
<group>
|
||||
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
||||
|
||||
{/* Sky / daylight pane */}
|
||||
<mesh position={[0, 0, 0.02]}>
|
||||
<planeGeometry args={[spec.width - 0.12, spec.height - 0.12]} />
|
||||
<meshStandardMaterial
|
||||
color={spec.lightColor}
|
||||
emissive={spec.lightColor}
|
||||
emissiveIntensity={0.85}
|
||||
roughness={0.05}
|
||||
metalness={0.02}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.92}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Soft sky gradient overlay */}
|
||||
<mesh position={[0, spec.height * 0.15, 0.03]}>
|
||||
<planeGeometry args={[spec.width - 0.2, spec.height * 0.5]} />
|
||||
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
||||
</mesh>
|
||||
|
||||
{/* 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 * Math.PI * 0.85}
|
||||
distance={14}
|
||||
decay={0}
|
||||
color={spec.lightColor}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) {
|
||||
return (
|
||||
<group>
|
||||
{windows.map((spec, i) => {
|
||||
const { position, rotation } = windowWorldPosition(spec, halfW, halfD);
|
||||
return (
|
||||
<group key={`${spec.wall}-${spec.x}-${i}`} position={position} rotation={rotation}>
|
||||
<SingleWindow spec={spec} trimColor={trimColor} />
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ceiling-mounted gallery track lights for even illumination. */
|
||||
export function GalleryTrackLights({
|
||||
width,
|
||||
depth,
|
||||
intensity,
|
||||
color,
|
||||
}: {
|
||||
width: number;
|
||||
depth: number;
|
||||
intensity: number;
|
||||
color: string;
|
||||
}) {
|
||||
const positions = useMemo(() => {
|
||||
const pts: [number, number, number][] = [];
|
||||
// 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);
|
||||
const z = -depth / 2 + (depth / (rows + 1)) * (r + 1);
|
||||
pts.push([x, WALL_HEIGHT - 0.15, z]);
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}, [width, depth]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{positions.map(([x, y, z], i) => (
|
||||
<group key={i} position={[x, y, z]}>
|
||||
<mesh rotation={[Math.PI, 0, 0]}>
|
||||
<cylinderGeometry args={[0.02, 0.025, 0.12, 8]} />
|
||||
<meshStandardMaterial color="#2a2a2a" metalness={0.8} roughness={0.25} />
|
||||
</mesh>
|
||||
<spotLight
|
||||
position={[0, -0.04, 0]}
|
||||
angle={0.7}
|
||||
penumbra={0.9}
|
||||
intensity={intensity}
|
||||
distance={12}
|
||||
decay={0}
|
||||
color={color}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Text } from '@react-three/drei';
|
||||
|
||||
const DOOR_WIDTH = 2.4;
|
||||
const DOOR_HEIGHT = 2.5;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
|
||||
/** Open archway connecting to the next movement wing. */
|
||||
export default function HallPassage({
|
||||
position,
|
||||
active,
|
||||
label,
|
||||
onActivate,
|
||||
wallColor = '#f0ebe3',
|
||||
trimColor = '#ddd5c8',
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
active: boolean;
|
||||
label: string;
|
||||
onActivate: () => void;
|
||||
wallColor?: string;
|
||||
trimColor?: string;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const highlight = active || hovered;
|
||||
const openingW = DOOR_WIDTH;
|
||||
const openingH = DOOR_HEIGHT;
|
||||
const jamb = 0.12;
|
||||
const faceZ = 0.04;
|
||||
|
||||
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
onActivate();
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<pointLight position={[0, openingH * 0.5, 0.3]} intensity={highlight ? 0.9 : 0.5} distance={5} color="#fff4e8" />
|
||||
|
||||
{/* Depth beyond passage */}
|
||||
<mesh position={[0, openingH * 0.5, 0.15]}>
|
||||
<planeGeometry args={[openingW * 0.9, openingH * 0.95]} />
|
||||
<meshStandardMaterial color="#fff8f0" emissive="#ffe8c8" emissiveIntensity={highlight ? 0.28 : 0.14} />
|
||||
</mesh>
|
||||
|
||||
{/* Side jambs */}
|
||||
{([-1, 1] as const).map((sign) => (
|
||||
<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]}>
|
||||
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, openingH + 0.22, faceZ]} rotation={[0, 0, Math.PI]}>
|
||||
<sphereGeometry args={[openingW / 2 + 0.04, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<meshStandardMaterial color={wallColor} roughness={0.88} />
|
||||
</mesh>
|
||||
|
||||
<group position={[0, openingH + 0.38, faceZ - 0.01]}>
|
||||
<mesh onClick={handleActivate} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
|
||||
<boxGeometry args={[1.4, 0.18, 0.02]} />
|
||||
<meshStandardMaterial
|
||||
color={highlight ? '#d4af37' : trimColor}
|
||||
roughness={0.3}
|
||||
metalness={0.5}
|
||||
emissive={highlight ? '#5a4010' : '#000000'}
|
||||
emissiveIntensity={highlight ? 0.2 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0, 0, -0.012]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
fontSize={0.1}
|
||||
color={highlight ? '#fff8e8' : '#4a3020'}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
</group>
|
||||
|
||||
<mesh
|
||||
position={[0, openingH / 2, faceZ - 0.12]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<planeGeometry args={[openingW * 1.1, openingH * 1.05]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export { DOOR_WIDTH, DOOR_HEIGHT, WALL_THICKNESS };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
.movements-flow-canvas {
|
||||
--stream-stroke: 54px;
|
||||
--portrait-size: 52px;
|
||||
--portrait-size: 39px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -47,12 +47,65 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movement-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movement-lifespan-dim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.movement-lifespan-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.16) 40%,
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(255, 230, 180, 0.45),
|
||||
inset 0 0 48px rgba(255, 255, 255, 0.08);
|
||||
border-left: 2px solid rgba(255, 220, 160, 0.65);
|
||||
border-right: 2px solid rgba(255, 220, 160, 0.65);
|
||||
}
|
||||
|
||||
.movements-flow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting.movements-flow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movement-branch-fast {
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.movement-stream-fast {
|
||||
stroke-opacity: 0.62;
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
@@ -60,21 +113,52 @@
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movement-stream-fill {
|
||||
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;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
|
||||
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.14;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.movement-stream-fill.movement-stream-highlighted {
|
||||
filter: brightness(1.75) saturate(1.45) contrast(1.12);
|
||||
}
|
||||
|
||||
.movement-stream-core.movement-stream-highlighted {
|
||||
opacity: 0.82;
|
||||
filter: brightness(1.55) saturate(1.2);
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
|
||||
.movement-branch.movement-branch-highlighted {
|
||||
filter: brightness(1.65) saturate(1.35) contrast(1.1);
|
||||
}
|
||||
|
||||
@keyframes stream-shimmer {
|
||||
@@ -90,13 +174,38 @@
|
||||
.movements-flow-artists {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.movements-flow-hits {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movements-flow-artists {
|
||||
.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;
|
||||
}
|
||||
|
||||
.artist-on-band {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-lifespan {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
@@ -110,38 +219,77 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
height: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
var(--lifespan-color, #e8d5b5) 15%,
|
||||
#fff 50%,
|
||||
var(--lifespan-color, #e8d5b5) 85%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
|
||||
border-radius: 1px;
|
||||
box-shadow:
|
||||
0 0 8px rgba(255, 255, 255, 0.55),
|
||||
0 0 16px var(--lifespan-color, #e8d5b5);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait {
|
||||
.artist-on-band .artist-portrait {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait:hover {
|
||||
.artist-on-band .artist-portrait:hover {
|
||||
transform: translate(-50%, -50%) scale(1.14);
|
||||
}
|
||||
|
||||
.artist-on-band-active .artist-portrait {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 4px 24px rgba(255, 220, 160, 0.65);
|
||||
}
|
||||
|
||||
.movements-flow-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
position: absolute;
|
||||
transform: translate(-4px, -100%);
|
||||
max-width: 160px;
|
||||
padding: 2px 6px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
z-index: 6;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.movement-flow-label-btn {
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
background: rgba(12, 10, 18, 0.72);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.movement-flow-label-btn:hover {
|
||||
background: rgba(24, 20, 32, 0.82);
|
||||
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.35);
|
||||
}
|
||||
|
||||
.movement-flow-label-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 220, 160, 0.65);
|
||||
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 {
|
||||
@@ -165,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;
|
||||
@@ -210,7 +358,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.movements-flow-canvas {
|
||||
--stream-stroke: 44px;
|
||||
--portrait-size: 44px;
|
||||
--portrait-size: 33px;
|
||||
}
|
||||
|
||||
.movements-flow-caption {
|
||||
@@ -218,8 +366,8 @@
|
||||
}
|
||||
|
||||
.artist-portrait {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: var(--portrait-size, 33px);
|
||||
height: var(--portrait-size, 33px);
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
|
||||
+2042
-368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
|
||||
interface Props {
|
||||
style: MovementInteriorStyle;
|
||||
width: number;
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
}
|
||||
|
||||
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
const pilasterPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.35, -halfD + 0.35],
|
||||
[halfW - 0.35, -halfD + 0.35],
|
||||
[-halfW + 0.35, halfD - 0.35],
|
||||
[halfW - 0.35, halfD - 0.35],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pilasterPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.8, 0]}>
|
||||
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.65, 0]}>
|
||||
<boxGeometry args={[0.28, 0.14, 0.28]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Wainscoting rail */}
|
||||
{[
|
||||
[0, -halfD + 0.09, width, 0.12] as const,
|
||||
[0, halfD - 0.09, width, 0.12] as const,
|
||||
[-halfW + 0.09, 0, 0.12, depth] as const,
|
||||
[halfW - 0.09, 0, 0.12, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 1.05, z]}>
|
||||
<boxGeometry args={[w, 0.08, d]} />
|
||||
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Coffered ceiling */}
|
||||
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
|
||||
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
|
||||
const cx = -halfW + 1.4 + col * 2.4;
|
||||
const cz = -halfD + 1.5 + row * 2.6;
|
||||
return (
|
||||
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<boxGeometry args={[2.0, 2.2, 0.06]} />
|
||||
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
|
||||
</mesh>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
return (
|
||||
<group>
|
||||
{/* Gilded cornice ring */}
|
||||
{[
|
||||
[0, -halfD + 0.06, width, 0.1] as const,
|
||||
[0, halfD - 0.06, width, 0.1] as const,
|
||||
[-halfW + 0.06, 0, 0.1, depth] as const,
|
||||
[halfW - 0.06, 0, 0.1, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 3.95, z]}>
|
||||
<boxGeometry args={[w, 0.18, d]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Wall panels */}
|
||||
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
|
||||
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
|
||||
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
|
||||
<mesh position={[0, 3.5, 0]}>
|
||||
<torusGeometry args={[0.55, 0.04, 8, 24]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
||||
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||
// never intrude on the ~0.7 m frame hang margin.
|
||||
const bays = useMemo(() => {
|
||||
const spacing = 4.2;
|
||||
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
|
||||
const step = depth / (count + 1);
|
||||
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||
}, [depth, halfD]);
|
||||
|
||||
const shaftHeight = 3.5;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
|
||||
{/* Compound pier: a heavier centre shaft flanked by colonnettes */}
|
||||
<mesh position={[0, shaftHeight / 2, 0]}>
|
||||
<cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
|
||||
<meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
|
||||
</mesh>
|
||||
{[-0.17, 0.17].map((dz) => (
|
||||
<mesh key={dz} position={[0, shaftHeight / 2, dz]}>
|
||||
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
|
||||
<meshStandardMaterial color="#9e9482" roughness={0.82} metalness={0.03} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Moulded base and foliate capital */}
|
||||
<mesh position={[0, 0.1, 0]}>
|
||||
<boxGeometry args={[0.28, 0.2, 0.52]} />
|
||||
<meshStandardMaterial color="#978d79" roughness={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, shaftHeight + 0.12, 0]}>
|
||||
<boxGeometry args={[0.3, 0.24, 0.56]} />
|
||||
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
{/* Vault springer resting on the capital */}
|
||||
<mesh position={[0, shaftHeight + 0.36, 0]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<boxGeometry args={[0.17, 0.17, 0.46]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.72} metalness={0.06} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Transverse rib arching across the nave between opposite piers */}
|
||||
<group position={[0, shaftHeight + 0.42, z]}>
|
||||
{Array.from({ length: 9 }, (_, s) => {
|
||||
const segs = 9;
|
||||
const t0 = s / segs;
|
||||
const t1 = (s + 1) / segs;
|
||||
const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2;
|
||||
const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34;
|
||||
const x0 = xAt(t0);
|
||||
const x1 = xAt(t1);
|
||||
const y0 = yAt(t0);
|
||||
const y1 = yAt(t1);
|
||||
const len = Math.hypot(x1 - x0, y1 - y0);
|
||||
return (
|
||||
<mesh
|
||||
key={s}
|
||||
position={[(x0 + x1) / 2, (y0 + y1) / 2, 0]}
|
||||
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
|
||||
>
|
||||
<boxGeometry args={[len * 1.06, 0.13, 0.16]} />
|
||||
<meshStandardMaterial color="#b0a58f" roughness={0.74} metalness={0.05} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Moulded string course running the length of both side walls */}
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (halfW - 0.05), 2.62, 0]}>
|
||||
<boxGeometry args={[0.12, 0.1, depth - 0.2]} />
|
||||
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Chancel-style cornice on the end wall */}
|
||||
<mesh position={[0, 3.96, -halfD + 0.1]}>
|
||||
<boxGeometry args={[Math.max(2.4, width - 0.4), 0.16, 0.18]} />
|
||||
<meshStandardMaterial color="#b0a58f" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{/* 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]}>
|
||||
<boxGeometry args={[0.12, 3.7, 0.12]} />
|
||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
||||
</mesh>
|
||||
<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>
|
||||
))}
|
||||
{/* 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, 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>
|
||||
{columns.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 2, 0]}>
|
||||
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
|
||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.05, 0]}>
|
||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SalonDetails({ trim }: { trim: string }) {
|
||||
return (
|
||||
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.3, 1.2, 32]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
|
||||
const trim = style.tints.trim;
|
||||
|
||||
switch (style.details) {
|
||||
case 'palazzo':
|
||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'baroque':
|
||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'gothic':
|
||||
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
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} halfD={halfD} trim={trim} />;
|
||||
case 'salon':
|
||||
return <SalonDetails trim={trim} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.mtree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.mtree-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mtree-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.mtree-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||
rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mtree-canvas.mtree-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mtree-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mtree-limb {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-limb-lit {
|
||||
opacity: 1;
|
||||
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||
}
|
||||
|
||||
.mtree-limb-dim {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.mtree-root {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.mtree-graft {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-graft-lit {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mtree-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.74);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-label:hover {
|
||||
background: rgba(30, 28, 40, 0.92);
|
||||
color: #fff6e0;
|
||||
}
|
||||
|
||||
.mtree-label-dim {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.mtree-out-of-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import {
|
||||
branchOriginYear,
|
||||
buildMovementTree,
|
||||
limbXAtYear,
|
||||
type MovementTreeNode,
|
||||
} from '../utils/movementTree';
|
||||
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementTree.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const SIDE_PAD = 56;
|
||||
/**
|
||||
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||
*
|
||||
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||
* stretched to full width with all of history in view is one long trunk under a
|
||||
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||
* step fans the crown out — the chart grows as you walk up it.
|
||||
*/
|
||||
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||
const MAX_FIT_BOOST = 2.4;
|
||||
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||
const FIT_ANIM_RATE = 9;
|
||||
const RIBBON_SAMPLES = 18;
|
||||
/** Vertical room a label needs. */
|
||||
const MIN_LABEL_HEIGHT_PX = 20;
|
||||
/**
|
||||
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||
* history is on screen; without these the modern crown fuses into one bar.
|
||||
*/
|
||||
const MIN_LIMB_RISE_PX = 30;
|
||||
const MIN_JUNCTION_RISE_PX = 38;
|
||||
/** A limb is never drawn thicker than this share of its own length. */
|
||||
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||
|
||||
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
const c = 3 * u * t * t;
|
||||
const d = t * t * t;
|
||||
return {
|
||||
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = 3 * u * u;
|
||||
const b = 6 * u * t;
|
||||
const c = 3 * t * t;
|
||||
return {
|
||||
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||
* zoomed-out view squeezes them almost flat.
|
||||
*/
|
||||
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||
const left: Pt[] = [];
|
||||
const right: Pt[] = [];
|
||||
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||
const t = i / RIBBON_SAMPLES;
|
||||
const p = cubicAt(p0, c1, c2, p3, t);
|
||||
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||
const len = Math.hypot(d.x, d.y) || 1;
|
||||
const nx = -d.y / len;
|
||||
const ny = d.x / len;
|
||||
const half = (w0 + (w1 - w0) * t) / 2;
|
||||
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||
}
|
||||
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||
const back = right
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((pt) => `L ${fmt(pt)}`)
|
||||
.join(' ');
|
||||
return `${forward} ${back} Z`;
|
||||
}
|
||||
|
||||
interface LimbShape {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
shade: string;
|
||||
/** Trunk / limb body. */
|
||||
d: string;
|
||||
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||
junction: string;
|
||||
/** Rounded tip cap. */
|
||||
tip: Pt & { r: number };
|
||||
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||
roots: string[];
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
labelVisible: boolean;
|
||||
yearRange: string;
|
||||
depth: number;
|
||||
inView: boolean;
|
||||
}
|
||||
|
||||
interface GraftShape {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||
* name, and the ones that would collide with an already-placed name — or fall
|
||||
* off the canvas — stay anonymous until you zoom in on them.
|
||||
*/
|
||||
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||
const candidates = limbs
|
||||
.map((limb, index) => ({ limb, index }))
|
||||
.filter(({ limb }) => limb.labelVisible)
|
||||
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||
|
||||
for (const { limb } of candidates) {
|
||||
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||
const box = {
|
||||
x0: limb.labelX - halfW,
|
||||
y0: limb.labelY - halfH,
|
||||
x1: limb.labelX + halfW,
|
||||
y1: limb.labelY + halfH,
|
||||
};
|
||||
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||
const collides = placed.some(
|
||||
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||
);
|
||||
if (offCanvas || collides) {
|
||||
limb.labelVisible = false;
|
||||
continue;
|
||||
}
|
||||
placed.push(box);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MovementTree({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const fitRef = useRef(1);
|
||||
const fitReadyRef = useRef(false);
|
||||
const fitTargetRef = useRef(1);
|
||||
const fitRafRef = useRef<number | null>(null);
|
||||
const fitLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||
// the tree, only travel along it.
|
||||
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const node of tree.nodes.values()) {
|
||||
const { start_year: s, end_year: e } = node.movement;
|
||||
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||
}
|
||||
return ids;
|
||||
}, [tree, viewStart, viewEnd]);
|
||||
|
||||
const targetScale = useMemo(() => {
|
||||
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||
const spread = Math.min(
|
||||
MAX_FIT_BOOST,
|
||||
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||
);
|
||||
|
||||
let visibleHalfSpan = 0;
|
||||
for (const id of visibleIds) {
|
||||
const node = tree.nodes.get(id);
|
||||
if (!node) continue;
|
||||
visibleHalfSpan = Math.max(
|
||||
visibleHalfSpan,
|
||||
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||
);
|
||||
}
|
||||
// Never let what is on screen spill off the canvas.
|
||||
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||
return Math.min(fitAll * spread, overflowCap);
|
||||
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fitTargetRef.current = targetScale;
|
||||
if (!fitReadyRef.current) {
|
||||
// First measured layout — adopt it instead of animating in from nothing.
|
||||
fitReadyRef.current = true;
|
||||
fitRef.current = targetScale;
|
||||
setFitScale(targetScale);
|
||||
return;
|
||||
}
|
||||
if (fitRafRef.current != null) return;
|
||||
|
||||
fitLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||
fitLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||
fitRef.current = fitTargetRef.current;
|
||||
setFitScale(fitTargetRef.current);
|
||||
fitRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
fitRef.current = next;
|
||||
setFitScale(next);
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
}, [targetScale]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { limbs, grafts } = useMemo(() => {
|
||||
const span = viewEnd - viewStart || 1;
|
||||
const centerX = canvas.w / 2;
|
||||
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||
|
||||
const limbList: LimbShape[] = [];
|
||||
const graftList: GraftShape[] = [];
|
||||
|
||||
/**
|
||||
* Pass 1 — screen geometry per movement.
|
||||
*
|
||||
* Two readability floors apply here, and only here: the structure and the
|
||||
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||
* of Fauvism can share one linear axis without the modern crown fusing
|
||||
* into a solid bar.
|
||||
*/
|
||||
const drawn = new Map<
|
||||
number,
|
||||
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||
>();
|
||||
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
if (end <= start) continue;
|
||||
|
||||
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||
const trueTipY = sy(end);
|
||||
const tip: Pt = {
|
||||
x: sx(limbXAtYear(node, end)),
|
||||
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||
};
|
||||
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||
const dy = tip.y - base.y;
|
||||
drawn.set(id, {
|
||||
base,
|
||||
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||
tip,
|
||||
wBase,
|
||||
wTip,
|
||||
});
|
||||
}
|
||||
|
||||
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||
const p = drawn.get(parentId)!;
|
||||
const total = p.base.y - p.tip.y || 1;
|
||||
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||
return {
|
||||
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||
};
|
||||
};
|
||||
|
||||
// Pass 2 — ribbons.
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const shape = drawn.get(id);
|
||||
if (!shape) continue;
|
||||
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
|
||||
const color = vividMovementColor(node.movement.color);
|
||||
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||
|
||||
// Junction: the limb grows out of its parent a little before its own
|
||||
// date, and always climbs far enough to read as a fork.
|
||||
let junction = '';
|
||||
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||
if (parent && drawn.has(parent.movement.id)) {
|
||||
const byDate = sy(branchOriginYear(parent, node));
|
||||
const origin = pointOnLimb(
|
||||
parent.movement.id,
|
||||
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||
);
|
||||
const from = origin.pt;
|
||||
const jdy = base.y - from.y;
|
||||
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||
}
|
||||
|
||||
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||
const roots: string[] = [];
|
||||
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||
const flare = Math.max(22, wBase * 1.4);
|
||||
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
const inView = visibleIds.has(id);
|
||||
const clampedStart = Math.max(start, viewStart);
|
||||
const clampedEnd = Math.min(end, viewEnd);
|
||||
const midYear = (clampedStart + clampedEnd) / 2;
|
||||
const labelY = Math.min(
|
||||
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||
base.y
|
||||
);
|
||||
|
||||
limbList.push({
|
||||
id,
|
||||
name: node.movement.name,
|
||||
color,
|
||||
shade: shadeMovementColor(color),
|
||||
d,
|
||||
junction,
|
||||
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||
roots,
|
||||
labelX: sx(limbXAtYear(node, midYear)),
|
||||
labelY,
|
||||
labelVisible: inView,
|
||||
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||
depth: node.depth,
|
||||
inView,
|
||||
});
|
||||
|
||||
for (const graftId of node.graftParentIds) {
|
||||
const graftParent = tree.nodes.get(graftId);
|
||||
if (!graftParent || !drawn.has(graftId)) continue;
|
||||
const byDate = sy(branchOriginYear(graftParent, node));
|
||||
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||
const from = origin.pt;
|
||||
const gdy = base.y - from.y;
|
||||
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||
graftList.push({
|
||||
key: `${graftId}-${id}`,
|
||||
d: ribbonPath(
|
||||
from,
|
||||
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||
base,
|
||||
gWidth * 0.7,
|
||||
gWidth
|
||||
),
|
||||
color: vividMovementColor(graftParent.movement.color),
|
||||
fromId: graftId,
|
||||
toId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||
return { limbs: limbList, grafts: graftList };
|
||||
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||
|
||||
/** A hovered movement lights up its whole descent line back to the root. */
|
||||
const lineageIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (hoveredId == null) return ids;
|
||||
let cursor: number | null = hoveredId;
|
||||
let guard = 0;
|
||||
while (cursor != null && guard++ < 64) {
|
||||
ids.add(cursor);
|
||||
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||
if (!node) break;
|
||||
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||
cursor = node.parentId;
|
||||
}
|
||||
return ids;
|
||||
}, [hoveredId, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||
// left-to-right zoom math.
|
||||
const invertedY = rect.height - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<div className="mtree-empty">
|
||||
<p>No art movements to grow a tree from yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anyInView = limbs.some((limb) => limb.inView);
|
||||
|
||||
return (
|
||||
<div className="mtree">
|
||||
<p className="mtree-caption">
|
||||
{t('captionTreeFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="mtree-svg"
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{limbs.map((limb) => (
|
||||
<linearGradient
|
||||
key={`grad-${limb.id}`}
|
||||
id={`mtree-limb-${limb.id}`}
|
||||
gradientUnits="objectBoundingBox"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor={limb.shade} />
|
||||
<stop offset="45%" stopColor={limb.color} />
|
||||
<stop offset="100%" stopColor={limb.shade} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<g className="mtree-grafts">
|
||||
{grafts.map((graft) => (
|
||||
<path
|
||||
key={graft.key}
|
||||
d={graft.d}
|
||||
fill={graft.color}
|
||||
className={`mtree-graft${
|
||||
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||
? ' mtree-graft-lit'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{limbs.map((limb) => {
|
||||
const lit = lineageIds.has(limb.id);
|
||||
const dim = hoveredId != null && !lit;
|
||||
return (
|
||||
<g
|
||||
key={limb.id}
|
||||
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||
dim ? ' mtree-limb-dim' : ''
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||
{limb.roots.map((d, i) => (
|
||||
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||
))}
|
||||
{limb.junction && (
|
||||
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
)}
|
||||
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{limbs
|
||||
.filter((limb) => limb.labelVisible)
|
||||
.map((limb) => (
|
||||
<button
|
||||
key={`label-${limb.id}`}
|
||||
type="button"
|
||||
className={`mtree-label${
|
||||
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||
}`}
|
||||
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||
title={`${limb.name} · ${limb.yearRange}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
{limb.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!anyInView && (
|
||||
<div className="mtree-out-of-range">
|
||||
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.painting-frame-annotated {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painting-frame-image-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painting-frame-image-wrap img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.painting-annotation-markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.painting-annotation-marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #1a1208;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.painting-annotation-marker:hover,
|
||||
.painting-annotation-marker-active {
|
||||
transform: translate(-50%, -50%) scale(1.12);
|
||||
box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.55), 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.painting-annotation-marker-technique { background: #7eb8da; }
|
||||
.painting-annotation-marker-composition { background: #c9a96e; }
|
||||
.painting-annotation-marker-symbolism { background: #b088cc; }
|
||||
.painting-annotation-marker-history { background: #8cbe8c; }
|
||||
.painting-annotation-marker-subject { background: #e8a040; }
|
||||
|
||||
.painting-annotations-panel {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.painting-annotations-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.painting-annotations-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.painting-annotation-card {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.2);
|
||||
background: rgba(201, 169, 110, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.painting-annotation-card-active {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.painting-annotation-card-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.painting-annotation-card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.painting-annotation-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.painting-annotation-category {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
}
|
||||
|
||||
.painting-annotation-label {
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.painting-annotation-body {
|
||||
margin: 0;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: rgba(232, 213, 181, 0.88);
|
||||
}
|
||||
|
||||
.painting-annotation-source {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
}
|
||||
|
||||
.painting-annotation-source cite {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.painting-annotation-source-link {
|
||||
display: inline-block;
|
||||
margin: 0 12px 10px;
|
||||
font-size: 11px;
|
||||
color: #7eb8da;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.painting-annotation-source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.painting-annotation-card-technique { border-left: 3px solid #7eb8da; }
|
||||
.painting-annotation-card-composition { border-left: 3px solid #c9a96e; }
|
||||
.painting-annotation-card-symbolism { border-left: 3px solid #b088cc; }
|
||||
.painting-annotation-card-history { border-left: 3px solid #8cbe8c; }
|
||||
.painting-annotation-card-subject { border-left: 3px solid #e8a040; }
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_KEYS: Record<string, string> = {
|
||||
technique: 'categoryTechnique',
|
||||
composition: 'categoryComposition',
|
||||
symbolism: 'categorySymbolism',
|
||||
history: 'categoryHistorical',
|
||||
subject: 'categorySubject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
annotations: PaintingAnnotation[];
|
||||
activeId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function PaintingAnnotationMarkers({
|
||||
annotations,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const positioned = annotations.filter(
|
||||
(a) => a.pos_x != null && a.pos_y != null && Number.isFinite(Number(a.pos_x)) && Number.isFinite(Number(a.pos_y))
|
||||
);
|
||||
if (!positioned.length) return null;
|
||||
|
||||
return (
|
||||
<div className="painting-annotation-markers" aria-hidden={false}>
|
||||
{positioned.map((ann) => {
|
||||
const number = annotations.indexOf(ann) + 1;
|
||||
const isActive = activeId === ann.id;
|
||||
return (
|
||||
<button
|
||||
key={ann.id}
|
||||
type="button"
|
||||
className={`painting-annotation-marker painting-annotation-marker-${ann.category}${isActive ? ' painting-annotation-marker-active' : ''}`}
|
||||
style={{ left: `${ann.pos_x}%`, top: `${ann.pos_y}%` }}
|
||||
title={ann.label || ann.body}
|
||||
aria-label={`Annotation ${number}: ${ann.label || ann.body}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(isActive ? null : ann.id);
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaintingAnnotationsPanel({
|
||||
annotations,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const { t } = useTranslation('annotations');
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
|
||||
const focusAnnotation = (id: number | null) => {
|
||||
onSelect(id);
|
||||
if (id != null) {
|
||||
cardRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">{t('title')}</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const categoryKey = CATEGORY_KEYS[ann.category];
|
||||
const category = categoryKey ? t(categoryKey) : ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
ref={(el) => {
|
||||
if (el) cardRefs.current.set(ann.id, el);
|
||||
else cardRefs.current.delete(ann.id);
|
||||
}}
|
||||
className={`painting-annotation-card painting-annotation-card-${ann.category}${isActive ? ' painting-annotation-card-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="painting-annotation-card-btn"
|
||||
onClick={() => focusAnnotation(isActive ? null : ann.id)}
|
||||
>
|
||||
<span className="painting-annotation-card-head">
|
||||
<span className="painting-annotation-number">{index + 1}</span>
|
||||
<span className="painting-annotation-category">{category}</span>
|
||||
{ann.label && <strong className="painting-annotation-label">{ann.label}</strong>}
|
||||
</span>
|
||||
<p className="painting-annotation-body">{ann.body}</p>
|
||||
{(ann.source_author || ann.source) && (
|
||||
<p className="painting-annotation-source">
|
||||
— {ann.source_author}
|
||||
{ann.source && <cite>, {ann.source}</cite>}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
{ann.source_url && (
|
||||
<a
|
||||
className="painting-annotation-source-link"
|
||||
href={ann.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('readSource')}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -113,18 +113,26 @@
|
||||
}
|
||||
|
||||
.influence-image-btn {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
max-height: 220px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: #2a1f15;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.influence-image-btn img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
@@ -180,9 +188,9 @@
|
||||
}
|
||||
|
||||
.influence-card-artist .influence-portrait-btn img {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.influence-period {
|
||||
@@ -290,9 +298,11 @@
|
||||
.influence-card img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.influence-info {
|
||||
@@ -392,6 +402,154 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.painting-frame-large .painting-frame-image-wrap img {
|
||||
width: 100%;
|
||||
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;
|
||||
@@ -499,6 +657,150 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.debug-more-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.55);
|
||||
border-radius: 6px;
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
color: #e8d5b5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-more-btn:hover:not(:disabled) {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
border-color: #c9a96e;
|
||||
}
|
||||
|
||||
.debug-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.debug-action-buttons-secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.debug-action-buttons-danger {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.debug-clear-btn,
|
||||
.debug-upload-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
border: 1px solid rgba(232, 120, 100, 0.55);
|
||||
background: rgba(232, 120, 100, 0.1);
|
||||
color: #e87864;
|
||||
}
|
||||
|
||||
.debug-clear-btn:hover:not(:disabled) {
|
||||
background: rgba(232, 120, 100, 0.2);
|
||||
border-color: #e87864;
|
||||
}
|
||||
|
||||
.debug-upload-btn {
|
||||
border: 1px solid rgba(140, 190, 140, 0.55);
|
||||
background: rgba(140, 190, 140, 0.1);
|
||||
color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-upload-btn:hover:not(:disabled) {
|
||||
background: rgba(140, 190, 140, 0.2);
|
||||
border-color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-clear-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.debug-remove-btn {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(200, 60, 60, 0.65);
|
||||
background: rgba(200, 60, 60, 0.12);
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
.debug-remove-btn:hover:not(:disabled) {
|
||||
background: rgba(200, 60, 60, 0.22);
|
||||
}
|
||||
|
||||
.debug-remove-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painting-frame-empty {
|
||||
min-height: 280px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.painting-frame-empty:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0 0 2px #c9a96e;
|
||||
}
|
||||
|
||||
.painting-frame-cleared {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.debug-image-status {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { useEffect, useState, 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 FixPaintingImageResult } from '../api/client';
|
||||
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?: (
|
||||
paintingId: number,
|
||||
fixResult: FixPaintingImageResult
|
||||
@@ -21,6 +34,8 @@ interface Props {
|
||||
paintingId: number,
|
||||
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 {
|
||||
@@ -126,6 +141,8 @@ function InfluenceCard({
|
||||
<img
|
||||
src={imageUrl(inf.artist_portrait)}
|
||||
alt={artistName}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
@@ -158,8 +175,10 @@ function InfluenceCard({
|
||||
title={`View ${inf.title}`}
|
||||
>
|
||||
<img
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
|
||||
alt={inf.title || 'Painting'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
@@ -180,16 +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 { painting, influencedBy, influenced } = data;
|
||||
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);
|
||||
@@ -197,8 +231,20 @@ export default function PaintingDetailView({
|
||||
const [debugError, setDebugError] = useState<string | null>(null);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const [markingChecked, setMarkingChecked] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [moreLoading, setMoreLoading] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getPaintingDebugImageSearchMore>> | null>(null);
|
||||
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 imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
|
||||
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;
|
||||
@@ -211,12 +257,34 @@ export default function PaintingDetailView({
|
||||
useEffect(() => {
|
||||
setFullscreen(false);
|
||||
setImageVersion(0);
|
||||
}, [painting.id]);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setActiveAnnotationId(null);
|
||||
setRemoving(false);
|
||||
setFixing(false);
|
||||
setClearing(false);
|
||||
setUploading(false);
|
||||
setUploadStatus(null);
|
||||
setMarkingChecked(false);
|
||||
setApplyingUrl(null);
|
||||
setMoreLoading(false);
|
||||
const notes = painting.curator_notes ?? '';
|
||||
setCuratorNotes(notes);
|
||||
setNotesDraft(notes);
|
||||
setEditingNotes(false);
|
||||
setSavingNotes(false);
|
||||
setNotesError(null);
|
||||
}, [painting.id, painting.curator_notes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode) {
|
||||
setDebugSearch(null);
|
||||
setDebugError(null);
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,22 +307,66 @@ 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);
|
||||
if (onPaintingImageFixed) {
|
||||
await onPaintingImageFixed(painting.id, fixResult);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFixFromSearch = async (
|
||||
imageUrl: string,
|
||||
context: { searchUrl: string; source: string; thumbUrl?: string }
|
||||
) => {
|
||||
const fixResult = await api.fixPaintingImage(painting.id, imageUrl, context);
|
||||
await applyImageUpdate(fixResult);
|
||||
setDebugSearch((prev) =>
|
||||
prev
|
||||
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
const handleFixImage = async () => {
|
||||
if (!debugSearch?.imageUrl || fixing) return;
|
||||
setFixing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
|
||||
await applyFixFromSearch(debugSearch.imageUrl, {
|
||||
searchUrl: debugSearch.searchUrl,
|
||||
source: debugSearch.source,
|
||||
thumbUrl: debugSearch.thumbUrl,
|
||||
});
|
||||
setImageVersion((v) => v + 1);
|
||||
if (onPaintingImageFixed) {
|
||||
await onPaintingImageFixed(painting.id, fixResult);
|
||||
}
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||
} finally {
|
||||
@@ -262,6 +374,46 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMore = useCallback(async () => {
|
||||
setMoreOpen(true);
|
||||
setMoreLoading(true);
|
||||
setMoreError(null);
|
||||
setMoreResults(null);
|
||||
try {
|
||||
const results = await api.getPaintingDebugImageSearchMore(painting.id);
|
||||
setMoreResults(results);
|
||||
} catch {
|
||||
setMoreError('Could not load search results.');
|
||||
} finally {
|
||||
setMoreLoading(false);
|
||||
}
|
||||
}, [painting.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode || !debugShowMore) return;
|
||||
void handleOpenMore();
|
||||
}, [debugMode, debugShowMore, painting.id, handleOpenMore]);
|
||||
|
||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||
if (fixing || applyingUrl) return;
|
||||
setApplyingUrl(item.imageUrl);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
|
||||
await applyFixFromSearch(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
thumbUrl: item.thumbUrl,
|
||||
});
|
||||
setMoreOpen(false);
|
||||
} catch (err) {
|
||||
setMoreError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||
} finally {
|
||||
setApplyingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkChecked = async () => {
|
||||
if (painting.checkup_checked || markingChecked) return;
|
||||
setMarkingChecked(true);
|
||||
@@ -276,6 +428,59 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearImage = async () => {
|
||||
if (clearing || fixing || uploading) return;
|
||||
setClearing(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
const result = await api.clearPaintingImage(painting.id);
|
||||
await applyImageUpdate(result);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not clear image.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveEntry = async () => {
|
||||
if (removing || fixing || clearing || uploading || !onPaintingRemoved) return;
|
||||
setRemoving(true);
|
||||
setDebugError(null);
|
||||
try {
|
||||
await onPaintingRemoved(painting.id, painting.artist_id);
|
||||
} catch (err) {
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not remove painting.');
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fullscreen) return;
|
||||
|
||||
@@ -299,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">
|
||||
@@ -309,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) => (
|
||||
@@ -356,22 +570,45 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<div
|
||||
className="painting-frame-large painting-frame-clickable"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setFullscreen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFullscreen(true);
|
||||
}
|
||||
}}
|
||||
title="View full screen"
|
||||
aria-label={`View ${painting.title} full screen`}
|
||||
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={
|
||||
displayImageSrc
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFullscreen(true);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={displayImageSrc ? 'View full screen' : undefined}
|
||||
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
<div className="painting-frame-image-wrap">
|
||||
{displayImageSrc ? (
|
||||
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
{displayImageSrc && annotations.length > 0 && (
|
||||
<PaintingAnnotationMarkers
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
onSelect={setActiveAnnotationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<PaintingAnnotationsPanel
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
onSelect={setActiveAnnotationId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCatalogNav && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -385,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>
|
||||
@@ -393,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) => (
|
||||
@@ -412,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, {
|
||||
@@ -432,33 +733,80 @@ 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>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-clear-btn"
|
||||
onClick={handleClearImage}
|
||||
disabled={clearing || fixing || uploading || imageCleared}
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-danger">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-remove-btn"
|
||||
onClick={handleRemoveEntry}
|
||||
disabled={removing || fixing || clearing || uploading}
|
||||
>
|
||||
{removing ? '…' : 'Remove entry'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{fullscreen && (
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose painting image"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
error={moreError}
|
||||
applyingUrl={applyingUrl}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
|
||||
{fullscreen && displayImageSrc && (
|
||||
<PaintingLightbox
|
||||
src={imageSrc}
|
||||
src={displayImageSrc}
|
||||
alt={painting.title}
|
||||
title={painting.title}
|
||||
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
|
||||
|
||||
@@ -33,15 +33,17 @@
|
||||
|
||||
.timeline-range {
|
||||
margin-left: 12px;
|
||||
color: #c9a96e;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
height: 88px;
|
||||
height: 96px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
@@ -53,6 +55,39 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.timeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-lifespan-dim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.timeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.22) 18%,
|
||||
rgba(255, 255, 255, 0.38) 50%,
|
||||
rgba(255, 255, 255, 0.22) 82%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(255, 240, 200, 0.55),
|
||||
inset 0 0 32px rgba(255, 255, 255, 0.2);
|
||||
border-left: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-right: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -120,18 +155,16 @@
|
||||
}
|
||||
|
||||
.historical-event-point .event-marker-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 210, 120, 0.95) 0%,
|
||||
rgba(255, 180, 90, 0.75) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historical-event-span {
|
||||
top: 20px;
|
||||
bottom: 24px;
|
||||
background: rgba(180, 70, 55, 0.28);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.75);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.75);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.historical-event-point::before {
|
||||
@@ -148,16 +181,13 @@
|
||||
}
|
||||
|
||||
.historical-event-span {
|
||||
top: 20px;
|
||||
bottom: 24px;
|
||||
background: rgba(180, 70, 55, 0.22);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.historical-event-span:hover,
|
||||
.historical-event-point:hover .event-marker-line {
|
||||
.historical-event-point:hover::before {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
@@ -197,24 +227,31 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
height: 34px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
border-left: 1px solid rgba(201, 169, 110, 0.4);
|
||||
height: 8px;
|
||||
border-left: 2px solid rgba(245, 230, 200, 0.75);
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.tick span {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #f5e6c8;
|
||||
text-shadow:
|
||||
0 0 8px rgba(0, 0, 0, 0.95),
|
||||
0 1px 2px rgba(0, 0, 0, 1),
|
||||
0 0 1px rgba(0, 0, 0, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -6,8 +7,18 @@ import {
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './Timeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
@@ -15,6 +26,7 @@ interface Props {
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
function yearToPercent(year: number, start: number, end: number): number {
|
||||
@@ -26,16 +38,43 @@ function formatYear(year: number): string {
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax }: Props) {
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1;
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval;
|
||||
for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(Math.round(w));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerWidth),
|
||||
[span, containerWidth]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
@@ -169,6 +208,15 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const left = yearToPercent(Math.max(lifespanHighlight.birthYear, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(lifespanHighlight.deathYear, viewEnd), viewStart, viewEnd);
|
||||
const width = right - left;
|
||||
if (width <= 0) return null;
|
||||
return { left, width, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<div className="timeline-wrapper">
|
||||
<div className="timeline-controls">
|
||||
@@ -182,7 +230,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="timeline-container"
|
||||
className={`timeline-container${lifespanBand ? ' timeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
@@ -218,6 +266,31 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="timeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.left > 0 && (
|
||||
<div className="timeline-lifespan-dim" style={{ left: 0, width: `${lifespanBand.left}%` }} />
|
||||
)}
|
||||
{lifespanBand.left + lifespanBand.width < 100 && (
|
||||
<div
|
||||
className="timeline-lifespan-dim"
|
||||
style={{
|
||||
left: `${lifespanBand.left + lifespanBand.width}%`,
|
||||
width: `${100 - lifespanBand.left - lifespanBand.width}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="timeline-lifespan-highlight"
|
||||
style={{
|
||||
left: `${lifespanBand.left}%`,
|
||||
width: `${lifespanBand.width}%`,
|
||||
['--lifespan-color' as string]: lifespanBand.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="timeline-events" aria-hidden={false}>
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
@@ -294,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
{t('captionClassicTimeline')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.timeline-event-guides {
|
||||
position: absolute;
|
||||
top: 78px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.timeline-event-guide-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 210, 120, 0.55) 0%,
|
||||
rgba(255, 180, 90, 0.35) 35%,
|
||||
rgba(255, 160, 80, 0.18) 100%
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(255, 190, 100, 0.25);
|
||||
}
|
||||
|
||||
.timeline-event-guide-span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(180, 70, 55, 0.1);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.35);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HISTORICAL_EVENTS, eventEndYear, eventInView } from '../data/historical-events';
|
||||
import './TimelineEventGuides.css';
|
||||
|
||||
function yearToPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
}
|
||||
|
||||
export default function TimelineEventGuides({ viewStart, viewEnd }: Props) {
|
||||
const events = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
|
||||
return (
|
||||
<div className="timeline-event-guides" aria-hidden>
|
||||
{events.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
|
||||
if (isSpan) {
|
||||
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (right <= 0 || left >= 100) return null;
|
||||
const width = Math.min(100, right) - Math.max(0, left);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="timeline-event-guide-span"
|
||||
style={{
|
||||
left: `${Math.max(0, left)}%`,
|
||||
width: `${width}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
|
||||
const left = yearToPercent(event.startYear, viewStart, viewEnd);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="timeline-event-guide-line"
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.vflow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.vflow-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vflow-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.vflow-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||
rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vflow-canvas.vflow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vflow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vflow-stream {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, filter 0.15s ease;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-stream-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.vflow-branch {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-branch-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.25) drop-shadow(0 0 4px rgba(255, 230, 180, 0.45));
|
||||
}
|
||||
|
||||
.vflow-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, 50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.72);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vflow-label:hover {
|
||||
background: rgba(30, 28, 40, 0.9);
|
||||
color: #fff6e0;
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
import './VerticalMovementBands.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface VLayout {
|
||||
movement: ArtMovement;
|
||||
/** Year span as % from bottom (earlier = lower). */
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
/** Lane center as % from left. */
|
||||
x: number;
|
||||
strokePx: number;
|
||||
displayColor: string;
|
||||
parentIds: number[];
|
||||
}
|
||||
|
||||
interface VBranch {
|
||||
key: string;
|
||||
d: string;
|
||||
colorFrom: string;
|
||||
colorTo: string;
|
||||
strokePx: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
const MAX_STROKE = 108; // ~300% of prior max (36)
|
||||
const MIN_STROKE = 54; // ~300% of prior min (18)
|
||||
const SIDE_PAD = 24;
|
||||
/** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */
|
||||
const PREFERRED_LANE_PITCH = MAX_STROKE + 16;
|
||||
const LANE_MIN_GAP_YEARS = 2;
|
||||
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function buildLineageParentMap(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
): Map<number, number[]> {
|
||||
const parents = new Map<number, number[]>();
|
||||
const visibleIds = new Set(visible.map((m) => m.id));
|
||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
||||
const parentId = nameToId.get(parentName);
|
||||
const childId = nameToId.get(childName);
|
||||
if (parentId == null || childId == null) continue;
|
||||
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
|
||||
const list = parents.get(childId) || [];
|
||||
if (!list.includes(parentId)) list.push(parentId);
|
||||
parents.set(childId, list);
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
function assignDepths(
|
||||
group: ArtMovement[],
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = new Map<number, number>();
|
||||
const visiting = new Set<number>();
|
||||
const visit = (id: number): number => {
|
||||
if (depths.has(id)) return depths.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const parents = lineageParents.get(id) || [];
|
||||
const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0;
|
||||
visiting.delete(id);
|
||||
depths.set(id, d);
|
||||
return d;
|
||||
};
|
||||
for (const m of group) visit(m.id);
|
||||
return depths;
|
||||
}
|
||||
|
||||
/** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */
|
||||
function centerOutOffsets(max = 64): number[] {
|
||||
const out = [0];
|
||||
for (let d = 1; d <= max; d++) out.push(d, -d);
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number {
|
||||
for (const delta of centerOutOffsets()) {
|
||||
const lane = preferred + delta;
|
||||
if (isFree(lane)) return lane;
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
/** Collapse signed lane indices to contiguous 0..n-1 (left → right). */
|
||||
function compactSignedLanes(laneById: Map<number, number>): void {
|
||||
const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b);
|
||||
const remap = new Map(usedSorted.map((lane, index) => [lane, index]));
|
||||
for (const [id, lane] of laneById) {
|
||||
laneById.set(id, remap.get(lane) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assignTemporalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
const spans = group
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
start: Math.max(m.start_year, viewStart),
|
||||
end: Math.min(m.end_year, viewEnd),
|
||||
depth: depths.get(m.id) ?? 0,
|
||||
}))
|
||||
.filter((s) => s.end > s.start)
|
||||
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
|
||||
|
||||
/** Signed lane → year when that lane frees up. */
|
||||
const laneEnds = new Map<number, number>();
|
||||
const laneById = new Map<number, number>();
|
||||
|
||||
for (const span of spans) {
|
||||
const parentLanes = (lineageParents.get(span.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => {
|
||||
const end = laneEnds.get(candidate);
|
||||
return end == null || end + LANE_MIN_GAP_YEARS <= span.start;
|
||||
});
|
||||
laneEnds.set(lane, span.end);
|
||||
laneById.set(span.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer one column per movement when the canvas is wide enough, so streams
|
||||
* do not stack in the same vertical lane. Fall back to temporal packing only
|
||||
* when there is not enough horizontal room.
|
||||
*
|
||||
* Lanes grow from the center outward (0, +1, −1, …) so the layout reads as a tree.
|
||||
*/
|
||||
function assignVerticalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>,
|
||||
canvasWidth: number
|
||||
): Map<number, number> {
|
||||
const usable = Math.max(200, canvasWidth - SIDE_PAD * 2);
|
||||
const minLanePx = PREFERRED_LANE_PITCH;
|
||||
const maxExclusive = Math.max(1, Math.floor(usable / minLanePx));
|
||||
|
||||
if (group.length <= maxExclusive) {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
// Roots first so the trunk claims center; children then fan around parents.
|
||||
const sorted = [...group].sort(
|
||||
(a, b) =>
|
||||
(depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) ||
|
||||
a.start_year - b.start_year ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const laneById = new Map<number, number>();
|
||||
const used = new Set<number>();
|
||||
for (const m of sorted) {
|
||||
const parentLanes = (lineageParents.get(m.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate));
|
||||
used.add(lane);
|
||||
laneById.set(m.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
return assignTemporalLanes(group, viewStart, viewEnd, lineageParents);
|
||||
}
|
||||
|
||||
function influenceCount(m: ArtMovement): number {
|
||||
const n = m.influence_link_count;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) {
|
||||
return {
|
||||
x: (xPct / 100) * widthPx,
|
||||
y: ((100 - yBottomPct) / 100) * heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */
|
||||
function verticalStreamPath(
|
||||
xPct: number,
|
||||
yStartPct: number,
|
||||
yEndPct: number,
|
||||
heightPx: number,
|
||||
widthPx: number
|
||||
): string {
|
||||
const start = pctToSvg(xPct, yEndPct, widthPx, heightPx);
|
||||
const end = pctToSvg(xPct, yStartPct, widthPx, heightPx);
|
||||
const midY = (start.y + end.y) / 2;
|
||||
const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06);
|
||||
return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/** Absolute-year anchors so pan/scroll keeps a constant connection angle. */
|
||||
function branchAnchorYears(
|
||||
parent: ArtMovement,
|
||||
child: ArtMovement,
|
||||
childIndex: number,
|
||||
childCount: number
|
||||
): { originYear: number; targetYear: number } | null {
|
||||
const parentSpan = parent.end_year - parent.start_year;
|
||||
if (parentSpan <= 0) return null;
|
||||
|
||||
const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22;
|
||||
let originYear = parent.start_year + parentSpan * tBase;
|
||||
const targetYear = child.start_year;
|
||||
|
||||
if (originYear >= targetYear) {
|
||||
originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1);
|
||||
}
|
||||
originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear));
|
||||
if (originYear >= targetYear) return null;
|
||||
|
||||
return { originYear, targetYear };
|
||||
}
|
||||
|
||||
function branchPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
// Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair.
|
||||
const c1x = x1 + dx * 0.35;
|
||||
const c1y = y1 + dy * 0.55;
|
||||
const c2x = x2 - dx * 0.25;
|
||||
const c2y = y2 - dy * 0.2;
|
||||
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */
|
||||
const LAYOUT_ANIM_RATE = 14;
|
||||
const LAYOUT_ANIM_EPS = 0.06;
|
||||
|
||||
interface AnimatedVFlow {
|
||||
layouts: VLayout[];
|
||||
branches: VBranch[];
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout {
|
||||
return {
|
||||
...to,
|
||||
yStart: lerp(from.yStart, to.yStart, t),
|
||||
yEnd: lerp(from.yEnd, to.yEnd, t),
|
||||
x: lerp(from.x, to.x, t),
|
||||
strokePx: lerp(from.strokePx, to.strokePx, t),
|
||||
};
|
||||
}
|
||||
|
||||
function vLayoutSettled(a: VLayout, b: VLayout): boolean {
|
||||
return (
|
||||
geomSettled(a.yStart, b.yStart) &&
|
||||
geomSettled(a.yEnd, b.yEnd) &&
|
||||
geomSettled(a.x, b.x) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch {
|
||||
const x1 = lerp(from.x1, to.x1, t);
|
||||
const y1 = lerp(from.y1, to.y1, t);
|
||||
const x2 = lerp(from.x2, to.x2, t);
|
||||
const y2 = lerp(from.y2, to.y2, t);
|
||||
const strokePx = lerp(from.strokePx, to.strokePx, t);
|
||||
return {
|
||||
...to,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
strokePx,
|
||||
d: branchPath(x1, y1, x2, y2),
|
||||
};
|
||||
}
|
||||
|
||||
function vBranchSettled(a: VBranch, b: VBranch): boolean {
|
||||
return (
|
||||
geomSettled(a.x1, b.x1) &&
|
||||
geomSettled(a.y1, b.y1) &&
|
||||
geomSettled(a.x2, b.x2) &&
|
||||
geomSettled(a.y2, b.y2) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function blendVFlow(
|
||||
from: AnimatedVFlow,
|
||||
to: AnimatedVFlow,
|
||||
t: number
|
||||
): { next: AnimatedVFlow; settled: boolean } {
|
||||
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
|
||||
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
|
||||
let settled = true;
|
||||
|
||||
const layouts = to.layouts.map((target) => {
|
||||
const prev = fromLayouts.get(target.movement.id);
|
||||
if (!prev) return target;
|
||||
if (vLayoutSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVLayout(prev, target, t);
|
||||
});
|
||||
|
||||
const branches = to.branches.map((target) => {
|
||||
const prev = fromBranches.get(target.key);
|
||||
if (!prev) return target;
|
||||
if (vBranchSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVBranch(prev, target, t);
|
||||
});
|
||||
|
||||
return { next: { layouts, branches }, settled };
|
||||
}
|
||||
|
||||
export default function VerticalMovementBands({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
|
||||
const [flowVisual, setFlowVisual] = useState<AnimatedVFlow | null>(null);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const flowVisualRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowTargetRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowRafRef = useRef<number | null>(null);
|
||||
const flowLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const visibleMovements = useMemo(
|
||||
() => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd),
|
||||
[movements, viewStart, viewEnd]
|
||||
);
|
||||
|
||||
const { layouts, branches } = useMemo(() => {
|
||||
if (visibleMovements.length === 0) {
|
||||
return { layouts: [] as VLayout[], branches: [] as VBranch[] };
|
||||
}
|
||||
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
|
||||
const laneIndex = assignVerticalLanes(
|
||||
visibleMovements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
lineageParents,
|
||||
canvasSize.w
|
||||
);
|
||||
|
||||
let maxLanes = 0;
|
||||
for (const m of visibleMovements) {
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
maxLanes = Math.max(maxLanes, lane + 1);
|
||||
}
|
||||
maxLanes = Math.max(1, maxLanes);
|
||||
|
||||
const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2);
|
||||
// Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster.
|
||||
const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH);
|
||||
const clusterWidth = lanePitch * maxLanes;
|
||||
const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2);
|
||||
const laneCentersPct: number[] = [];
|
||||
for (let lane = 0; lane < maxLanes; lane++) {
|
||||
const centerPx = startX + lanePitch * lane + lanePitch / 2;
|
||||
laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100;
|
||||
}
|
||||
|
||||
const maxInf = Math.max(1, ...visibleMovements.map(influenceCount));
|
||||
const layoutById = new Map<number, VLayout>();
|
||||
for (const m of visibleMovements) {
|
||||
const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd);
|
||||
const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (yEnd <= yStart) continue;
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
const baseStroke =
|
||||
MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE);
|
||||
// Allow nearly full preferred stroke; only shrink if the pitch is forced smaller.
|
||||
const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88);
|
||||
layoutById.set(m.id, {
|
||||
movement: m,
|
||||
yStart,
|
||||
yEnd,
|
||||
x: laneCentersPct[lane] ?? 50,
|
||||
strokePx,
|
||||
displayColor: vividMovementColor(m.color),
|
||||
parentIds: lineageParents.get(m.id) || [],
|
||||
});
|
||||
}
|
||||
|
||||
const childIdsByParent = new Map<number, number[]>();
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
if (!layoutById.has(parentId)) continue;
|
||||
const children = childIdsByParent.get(parentId) || [];
|
||||
children.push(layout.movement.id);
|
||||
childIdsByParent.set(parentId, children);
|
||||
}
|
||||
}
|
||||
for (const children of childIdsByParent.values()) {
|
||||
children.sort((a, b) => {
|
||||
const la = layoutById.get(a)!;
|
||||
const lb = layoutById.get(b)!;
|
||||
return la.x - lb.x || la.movement.start_year - lb.movement.start_year;
|
||||
});
|
||||
}
|
||||
|
||||
const branchList: VBranch[] = [];
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
const parent = layoutById.get(parentId);
|
||||
if (!parent) continue;
|
||||
const children = childIdsByParent.get(parentId) || [layout.movement.id];
|
||||
const childIndex = children.indexOf(layout.movement.id);
|
||||
const anchors = branchAnchorYears(
|
||||
parent.movement,
|
||||
layout.movement,
|
||||
childIndex,
|
||||
children.length
|
||||
);
|
||||
if (!anchors) continue;
|
||||
|
||||
// Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable.
|
||||
const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd);
|
||||
const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd);
|
||||
const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h);
|
||||
const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h);
|
||||
branchList.push({
|
||||
key: `${parentId}-${layout.movement.id}`,
|
||||
d: branchPath(from.x, from.y, to.x, to.y),
|
||||
colorFrom: parent.displayColor,
|
||||
colorTo: layout.displayColor,
|
||||
strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55),
|
||||
fromId: parentId,
|
||||
toId: layout.movement.id,
|
||||
x1: from.x,
|
||||
y1: from.y,
|
||||
x2: to.x,
|
||||
y2: to.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year),
|
||||
branches: branchList,
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target: AnimatedVFlow = { layouts, branches };
|
||||
flowTargetRef.current = target;
|
||||
|
||||
if (!flowVisualRef.current) {
|
||||
flowVisualRef.current = target;
|
||||
setFlowVisual(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowRafRef.current != null) return;
|
||||
|
||||
flowLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const prev = flowVisualRef.current;
|
||||
const goal = flowTargetRef.current;
|
||||
if (!prev || !goal) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
|
||||
flowLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
|
||||
const { next, settled } = blendVFlow(prev, goal, t);
|
||||
flowVisualRef.current = settled ? goal : next;
|
||||
setFlowVisual(flowVisualRef.current);
|
||||
|
||||
if (settled) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
}, [layouts, branches]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flowRafRef.current != null) {
|
||||
cancelAnimationFrame(flowRafRef.current);
|
||||
flowRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
const invertedClientY = rect.bottom - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedClientY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (visibleMovements.length === 0) {
|
||||
return (
|
||||
<div className="vflow-empty">
|
||||
<p>No art movements in this time range. Zoom out to explore more periods.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayLayouts = flowVisual?.layouts ?? layouts;
|
||||
const displayBranches = flowVisual?.branches ?? branches;
|
||||
|
||||
return (
|
||||
<div className="vflow">
|
||||
<p className="vflow-caption">
|
||||
{t('captionVerticalFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`vflow-canvas${panning ? ' vflow-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="vflow-svg"
|
||||
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{displayBranches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`grad-${branch.key}`}
|
||||
id={`vflow-branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.9} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.9} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const d = verticalStreamPath(
|
||||
layout.x,
|
||||
layout.yStart,
|
||||
layout.yEnd,
|
||||
canvasSize.h,
|
||||
canvasSize.w
|
||||
);
|
||||
const highlighted = hoveredMovementId === layout.movement.id;
|
||||
return (
|
||||
<path
|
||||
key={layout.movement.id}
|
||||
d={d}
|
||||
className={`vflow-stream${highlighted ? ' vflow-stream-highlighted' : ''}`}
|
||||
stroke={layout.displayColor}
|
||||
fill="none"
|
||||
style={{ strokeWidth: layout.strokePx }}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{displayBranches.map((branch) => {
|
||||
const highlighted =
|
||||
hoveredMovementId != null &&
|
||||
(branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId);
|
||||
return (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className={`vflow-branch${highlighted ? ' vflow-branch-highlighted' : ''}`}
|
||||
stroke={`url(#vflow-branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
style={{ strokeWidth: branch.strokePx }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const midY = (layout.yStart + layout.yEnd) / 2;
|
||||
return (
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
type="button"
|
||||
className="vflow-label"
|
||||
style={{
|
||||
left: `${layout.x}%`,
|
||||
bottom: `${midY}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
>
|
||||
{layout.movement.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
.vtimeline-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(90deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-right: 2px solid #c9a96e;
|
||||
padding: 8px 8px 12px;
|
||||
z-index: 100;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #c9a96e;
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
color: #e8d5b5;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vtimeline-controls button:hover {
|
||||
background: rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.vtimeline-range {
|
||||
width: 100%;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.vtimeline-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtimeline-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vtimeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.vtimeline-era-block {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 28px;
|
||||
margin: 0;
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtimeline-era-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 245, 220, 0.92);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-dim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.28) 50%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 240, 200, 0.5);
|
||||
border-top: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-bottom: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.vtimeline-events {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark,
|
||||
.vtimeline-event-span {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(232, 196, 120, 0.55);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark {
|
||||
height: 3px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.vtimeline-event-span {
|
||||
min-height: 4px;
|
||||
background: rgba(232, 196, 120, 0.28);
|
||||
border-left: 2px solid rgba(232, 196, 120, 0.7);
|
||||
}
|
||||
|
||||
.vtimeline-event-label {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
margin-left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
writing-mode: horizontal-tb;
|
||||
font-size: 9px;
|
||||
color: rgba(245, 230, 200, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vtimeline-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-tick {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: 26px;
|
||||
transform: translateY(50%);
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.35);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.vtimeline-tick span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
line-height: 1;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.vtimeline-brush {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
z-index: 8;
|
||||
cursor: ns-resize;
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
.vtimeline-brush-start {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.vtimeline-brush-end {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.vtimeline-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-family: 'Georgia', serif;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vtimeline-wrapper {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
eventEndYear,
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './VerticalTimeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
/** Earlier years at the bottom (0%), later at the top (100%). */
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) return `${Math.abs(year)} BCE`;
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
function getEraColor(name: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
Ancient: 'rgba(139,115,85,0.7)',
|
||||
Medieval: 'rgba(74,85,104,0.7)',
|
||||
Renaissance: 'rgba(184,134,11,0.7)',
|
||||
Baroque: 'rgba(139,0,0,0.6)',
|
||||
'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)',
|
||||
Modern: 'rgba(100,100,120,0.6)',
|
||||
Contemporary: 'rgba(60,60,80,0.7)',
|
||||
};
|
||||
return colors[name] || 'rgba(100,100,100,0.5)';
|
||||
}
|
||||
|
||||
export default function VerticalTimeline({
|
||||
eras,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
onViewChange,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
lifespanHighlight,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const dragStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setContainerHeight(Math.round(h));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerHeight, 56),
|
||||
[span, containerHeight]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Bottom = early: invert Y ratio so scroll-at-bottom zooms around early years.
|
||||
const ratioFromTop = (e.clientY - rect.top) / rect.height;
|
||||
const ratio = 1 - ratioFromTop;
|
||||
const centerYear = viewStart + ratio * span;
|
||||
const factor = e.deltaY > 0 ? 1.15 : 0.85;
|
||||
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||
let newStart = centerYear - ratio * newSpan;
|
||||
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(newStart), Math.round(newEnd));
|
||||
},
|
||||
[viewStart, span, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, mode: 'start' | 'end' | 'pan') => {
|
||||
e.preventDefault();
|
||||
setDragging(mode);
|
||||
dragStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Drag up (negative clientY delta) → toward later years at top → increase view.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
const yearDelta = -(dy / rect.height) * span;
|
||||
|
||||
if (dragging === 'pan') {
|
||||
let ns = dragStart.current.viewStart - yearDelta;
|
||||
let ne = dragStart.current.viewEnd - yearDelta;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
} else if (dragging === 'start') {
|
||||
const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta);
|
||||
onViewChange(Math.round(ns), viewEnd);
|
||||
} else {
|
||||
const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta);
|
||||
onViewChange(viewStart, Math.round(ne));
|
||||
}
|
||||
};
|
||||
const onUp = () => setDragging(null);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]);
|
||||
|
||||
const zoomIn = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.max(10, span * 0.5);
|
||||
onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2));
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.min(absoluteMax - absoluteMin, span * 2);
|
||||
let ns = center - newSpan / 2;
|
||||
let ne = center + newSpan / 2;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
};
|
||||
|
||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||
|
||||
const zoomToEra = useCallback(
|
||||
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const eraSpan = era.end_year - era.start_year;
|
||||
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||
let start = Math.max(absoluteMin, era.start_year - padding);
|
||||
let end = Math.min(absoluteMax, era.end_year + padding);
|
||||
if (end - start < 10) {
|
||||
const center = (era.start_year + era.end_year) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(end));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const zoomToEvent = useCallback(
|
||||
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const end = eventEndYear(event);
|
||||
const eventSpan = Math.max(end - event.startYear, 1);
|
||||
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||
let start = Math.max(absoluteMin, event.startYear - padding);
|
||||
let endView = Math.min(absoluteMax, end + padding);
|
||||
if (endView - start < 10) {
|
||||
const center = (event.startYear + end) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(endView));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
const minLabelGapYears = span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : 5;
|
||||
let lastLabelYear = -Infinity;
|
||||
return inView.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||
if (showLabel) lastLabelYear = labelAnchor;
|
||||
return { event, showLabel };
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const bottom = yearToBottomPercent(
|
||||
Math.max(lifespanHighlight.birthYear, viewStart),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const top = yearToBottomPercent(
|
||||
Math.min(lifespanHighlight.deathYear, viewEnd),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const height = top - bottom;
|
||||
if (height <= 0) return null;
|
||||
return { bottom, height, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<aside className="vtimeline-wrapper">
|
||||
<div className="vtimeline-controls">
|
||||
<button type="button" onClick={zoomIn} title="Zoom in">
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={zoomOut} title="Zoom out">
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={resetView} title="Reset view">
|
||||
⟲
|
||||
</button>
|
||||
<span className="vtimeline-range">
|
||||
{formatYear(viewStart)}
|
||||
<br />—<br />
|
||||
{formatYear(viewEnd)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`vtimeline-container${lifespanBand ? ' vtimeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
<div className="vtimeline-track">
|
||||
{eras.map((era) => {
|
||||
const bottom = yearToBottomPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (top <= 0 || bottom >= 100) return null;
|
||||
const height = Math.min(100, top) - Math.max(0, bottom);
|
||||
return (
|
||||
<button
|
||||
key={era.id}
|
||||
type="button"
|
||||
className="vtimeline-era-block"
|
||||
style={{
|
||||
bottom: `${Math.max(0, bottom)}%`,
|
||||
height: `${height}%`,
|
||||
borderBottom: era.start_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
borderTop: era.end_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
background: `linear-gradient(0deg,
|
||||
${era.start_definite ? 'var(--era-color)' : 'transparent'} 0%,
|
||||
var(--era-color) 15%,
|
||||
var(--era-color) 85%,
|
||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||
['--era-color' as string]: getEraColor(era.name),
|
||||
}}
|
||||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}`}
|
||||
onClick={(e) => zoomToEra(era, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="vtimeline-era-label">{era.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="vtimeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.bottom > 0 && (
|
||||
<div className="vtimeline-lifespan-dim" style={{ bottom: 0, height: `${lifespanBand.bottom}%` }} />
|
||||
)}
|
||||
{lifespanBand.bottom + lifespanBand.height < 100 && (
|
||||
<div
|
||||
className="vtimeline-lifespan-dim"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom + lifespanBand.height}%`,
|
||||
height: `${100 - lifespanBand.bottom - lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vtimeline-lifespan-highlight"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom}%`,
|
||||
height: `${lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vtimeline-events">
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
if (isSpan) {
|
||||
const bottom = yearToBottomPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (top <= bottom) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-span"
|
||||
style={{ bottom: `${bottom}%`, height: `${top - bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const bottom = yearToBottomPercent(event.startYear, viewStart, viewEnd);
|
||||
if (bottom < 0 || bottom > 100) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-mark"
|
||||
style={{ bottom: `${bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="vtimeline-ticks">
|
||||
{ticks.map((year) => (
|
||||
<div
|
||||
key={year}
|
||||
className="vtimeline-tick"
|
||||
style={{ bottom: `${yearToBottomPercent(year, viewStart, viewEnd)}%` }}
|
||||
>
|
||||
<span>{formatYear(year)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-start"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'start');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-end"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'end');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -380,3 +380,48 @@
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.movement-hall-nav-list {
|
||||
list-style: none;
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 24, 18, 0.6);
|
||||
color: #e8d5b5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list button:hover,
|
||||
.movement-hall-nav-active {
|
||||
border-color: rgba(212, 175, 55, 0.75) !important;
|
||||
background: rgba(60, 48, 32, 0.85) !important;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.movement-hall-exit-timeline {
|
||||
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
@@ -0,0 +1,97 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
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();
|
||||
applyAuthState(me, setRole, setUsername, setPermissions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (user: string, password: string) => {
|
||||
const me = await loginCurator(user, password);
|
||||
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,
|
||||
permissions,
|
||||
isCurator: role === 'admin' || role === 'curator',
|
||||
isAdmin: role === 'admin',
|
||||
loading,
|
||||
can,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[role, username, permissions, loading, can, login, logout, refresh]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -11,10 +11,19 @@ export interface HistoricalEvent {
|
||||
|
||||
export const HISTORICAL_EVENTS: readonly HistoricalEvent[] = [
|
||||
{ id: 'fall-rome', name: 'Fall of Rome', startYear: 476 },
|
||||
{ id: 'charlemagne', name: 'Charlemagne crowned emperor', startYear: 800 },
|
||||
{ id: 'battle-hastings', name: 'Battle of Hastings', startYear: 1066 },
|
||||
{ id: 'first-crusade', name: 'First Crusade', startYear: 1096, endYear: 1099 },
|
||||
{ id: 'magna-carta', name: 'Magna Carta', startYear: 1215 },
|
||||
{ id: 'black-death', name: 'Black Death', startYear: 1347 },
|
||||
{ id: 'printing-press', name: 'Printing press', startYear: 1450 },
|
||||
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||
{ id: 'fall-constantinople', name: 'Fall of Constantinople', startYear: 1453 },
|
||||
{ id: 'columbus', name: 'Columbus reaches the Americas', startYear: 1492 },
|
||||
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||
{ id: 'thirty-years-war', name: 'Thirty Years\' War', startYear: 1618, endYear: 1648, shortLabel: '30 Years\' War' },
|
||||
{ id: 'english-civil-war', name: 'English Civil War', startYear: 1642, endYear: 1651 },
|
||||
{ id: 'glorious-revolution', name: 'Glorious Revolution', startYear: 1688 },
|
||||
{ id: 'war-spanish-succession', name: 'War of the Spanish Succession', startYear: 1701, endYear: 1714, shortLabel: 'Spanish Succession' },
|
||||
{ id: 'american-revolution', name: 'American Revolution', startYear: 1776 },
|
||||
{ id: 'french-revolution', name: 'French Revolution', startYear: 1789 },
|
||||
{ id: 'waterloo', name: 'Battle of Waterloo', startYear: 1815 },
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
import type { ArtMovement } from '../types';
|
||||
import type { SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||
|
||||
export type GalleryWindowStyle =
|
||||
| 'roman-arch'
|
||||
| 'gothic-lancet'
|
||||
| 'baroque-pair'
|
||||
| 'sash'
|
||||
| 'factory'
|
||||
| 'skylight'
|
||||
| 'art-nouveau'
|
||||
| 'glass-block'
|
||||
| 'clerestory'
|
||||
| 'round-oculus';
|
||||
|
||||
export interface GalleryWindowSpec {
|
||||
wall: 'back' | 'left' | 'right' | 'ceiling';
|
||||
/** Offset along wall axis from center (meters). */
|
||||
x: number;
|
||||
/** Vertical center height (meters). */
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
style: GalleryWindowStyle;
|
||||
lightColor: string;
|
||||
lightIntensity: number;
|
||||
}
|
||||
|
||||
export interface MovementInteriorStyle {
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
surfaces: {
|
||||
wall: SurfaceTextureKind;
|
||||
wallSide?: SurfaceTextureKind;
|
||||
ceiling: SurfaceTextureKind;
|
||||
floor: SurfaceTextureKind;
|
||||
};
|
||||
tints: {
|
||||
wall: string;
|
||||
wallSide?: string;
|
||||
ceiling: string;
|
||||
floor: string;
|
||||
trim: string;
|
||||
};
|
||||
titleColor: string;
|
||||
ambient: number;
|
||||
warmLight: string;
|
||||
sunLight: string;
|
||||
fog: string;
|
||||
background: string;
|
||||
doorWood: [string, string, string];
|
||||
windows: GalleryWindowSpec[];
|
||||
trackLights: number;
|
||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||
lightScale: number;
|
||||
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||
}
|
||||
|
||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||
return specs;
|
||||
}
|
||||
|
||||
function mk(
|
||||
id: string,
|
||||
label: string,
|
||||
subtitle: string,
|
||||
surfaces: MovementInteriorStyle['surfaces'],
|
||||
tints: MovementInteriorStyle['tints'],
|
||||
opts: Partial<Omit<MovementInteriorStyle, 'id' | 'label' | 'subtitle' | 'surfaces' | 'tints'>> & {
|
||||
details: MovementInteriorStyle['details'];
|
||||
windows: GalleryWindowSpec[];
|
||||
}
|
||||
): MovementInteriorStyle {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
subtitle,
|
||||
surfaces,
|
||||
tints,
|
||||
titleColor: opts.titleColor ?? '#4a3020',
|
||||
ambient: opts.ambient ?? 0.55,
|
||||
warmLight: opts.warmLight ?? '#fff4e8',
|
||||
sunLight: opts.sunLight ?? '#fffaf0',
|
||||
fog: opts.fog ?? '#1a1814',
|
||||
background: opts.background ?? '#121010',
|
||||
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
||||
windows: opts.windows,
|
||||
trackLights: opts.trackLights ?? 0.8,
|
||||
lightScale: opts.lightScale ?? 1,
|
||||
details: opts.details,
|
||||
};
|
||||
}
|
||||
|
||||
/** Unique photo-real interior per movement (keyed by database id). */
|
||||
const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
27: mk(
|
||||
'ancient-classical',
|
||||
'Roman atrium',
|
||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
||||
{
|
||||
details: 'classical',
|
||||
titleColor: '#5a4830',
|
||||
ambient: 0.62,
|
||||
warmLight: '#fff8e8',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
|
||||
{ wall: 'back', x: 2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 }
|
||||
),
|
||||
trackLights: 0.4,
|
||||
doorWood: ['#6a5840', '#7a6848', '#5a4830'],
|
||||
}
|
||||
),
|
||||
28: mk(
|
||||
'byzantine-sanctuary',
|
||||
'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: 'byzantine',
|
||||
titleColor: '#e8d8b0',
|
||||
ambient: 0.44,
|
||||
warmLight: '#ffdca8',
|
||||
sunLight: '#ffdca0',
|
||||
fog: '#100b07',
|
||||
background: '#0a0705',
|
||||
windows: windows(
|
||||
{ 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.35,
|
||||
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
|
||||
lightScale: 0.3,
|
||||
doorWood: ['#3a2416', '#4c3220', '#241608'],
|
||||
}
|
||||
),
|
||||
29: mk(
|
||||
'gothic-cathedral',
|
||||
'Gothic nave',
|
||||
'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
|
||||
{ wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
|
||||
{ wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
|
||||
{
|
||||
details: 'gothic',
|
||||
titleColor: '#efe4cc',
|
||||
ambient: 0.6,
|
||||
warmLight: '#ffe6bc',
|
||||
sunLight: '#dbe8ff',
|
||||
// Cool shadowed stone, but never a pure void — walls must stay readable.
|
||||
fog: '#1b1a18',
|
||||
background: '#131211',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
|
||||
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
|
||||
{ wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }
|
||||
),
|
||||
trackLights: 0.5,
|
||||
// Naves are lit by window shafts against shadowed stone, not flooded.
|
||||
lightScale: 0.26,
|
||||
}
|
||||
),
|
||||
30: mk(
|
||||
'early-renaissance-palazzo',
|
||||
'Florentine palazzo',
|
||||
'Lime-washed walls · terracotta accents · arched windows',
|
||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||
{ wall: 'back', x: 2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 2.2, height: 1.4, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.6 }
|
||||
),
|
||||
}
|
||||
),
|
||||
31: mk(
|
||||
'high-renaissance-palazzo',
|
||||
'Roman palazzo',
|
||||
'Marble and stucco · coffered ceiling · checker floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 }
|
||||
),
|
||||
}
|
||||
),
|
||||
32: mk(
|
||||
'northern-renaissance-hall',
|
||||
'Flemish panel hall',
|
||||
'Oak wainscoting · leaded glass · herringbone floor',
|
||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
||||
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#f0e8d8',
|
||||
warmLight: '#ffe8c8',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
|
||||
{ wall: 'back', x: 2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 1.8, height: 1.4, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.4 }
|
||||
),
|
||||
doorWood: ['#4a3020', '#5c3a28', '#3a2010'],
|
||||
}
|
||||
),
|
||||
33: mk(
|
||||
'mannerist-villa',
|
||||
'Mannerist gallery',
|
||||
'Dramatic stucco · elongated windows · veined marble',
|
||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{
|
||||
details: 'baroque',
|
||||
titleColor: '#4a2818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
{ wall: 'back', x: 1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 1.6, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
|
||||
),
|
||||
}
|
||||
),
|
||||
34: mk(
|
||||
'baroque-palace',
|
||||
'Baroque state gallery',
|
||||
'Crimson velvet · gilded stucco · grand windows',
|
||||
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' },
|
||||
{
|
||||
details: 'baroque',
|
||||
titleColor: '#f0d890',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
fog: '#100808',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 3.2, height: 2.2, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 4.5 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
|
||||
),
|
||||
trackLights: 0.6,
|
||||
doorWood: ['#1a1008', '#2a1810', '#120c06'],
|
||||
}
|
||||
),
|
||||
35: mk(
|
||||
'rococo-salon',
|
||||
'Rococo salon',
|
||||
'Pastel painted walls · gilt trim · chevron parquet',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#6a4858',
|
||||
warmLight: '#fff0f0',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
|
||||
{ wall: 'back', x: 2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
}
|
||||
),
|
||||
36: mk(
|
||||
'neoclassical-museum',
|
||||
'Neoclassical museum',
|
||||
'White painted walls · coffered ceiling · skylit salon',
|
||||
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{
|
||||
details: 'neoclassical',
|
||||
titleColor: '#4a4844',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.7,
|
||||
}
|
||||
),
|
||||
37: mk(
|
||||
'romantic-gothic-revival',
|
||||
'Romantic gallery',
|
||||
'Dark walnut paneling · pointed windows · Persian carpet tones',
|
||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.5,
|
||||
warmLight: '#ffe0c0',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
|
||||
{ wall: 'right', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.2 }
|
||||
),
|
||||
}
|
||||
),
|
||||
38: mk(
|
||||
'realist-bourgeois',
|
||||
'Realist picture gallery',
|
||||
'Warm painted walls · bourgeois salon · oak parquet',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||
),
|
||||
}
|
||||
),
|
||||
39: mk(
|
||||
'impressionist-salon',
|
||||
'Impressionist salon',
|
||||
'North-light skylight · pale painted walls · herringbone floor',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{
|
||||
details: 'salon',
|
||||
ambient: 0.68,
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
{ wall: 'back', x: -2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.5,
|
||||
}
|
||||
),
|
||||
40: mk(
|
||||
'post-impressionist-atelier',
|
||||
'Montmartre atelier',
|
||||
'Painted studio walls · large north window · worn floorboards',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
||||
{
|
||||
details: 'atelier',
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
||||
),
|
||||
}
|
||||
),
|
||||
41: mk(
|
||||
'symbolist-chamber',
|
||||
'Symbolist chamber',
|
||||
'Deep green painted walls · amber window glow · dark parquet',
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#d8e8c8',
|
||||
ambient: 0.55,
|
||||
warmLight: '#ffd898',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.4, width: 1.0, height: 1.8, style: 'sash', lightColor: '#ffb860', lightIntensity: 2.0 },
|
||||
{ wall: 'right', x: 0, y: 2.6, width: 0.8, height: 1.4, style: 'gothic-lancet', lightColor: '#ffd080', lightIntensity: 1.6 }
|
||||
),
|
||||
trackLights: 0.9,
|
||||
}
|
||||
),
|
||||
42: mk(
|
||||
'art-nouveau-salon',
|
||||
'Art Nouveau salon',
|
||||
'Painted plaster · stained glass · terrazzo floor',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#3a5840',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 1.4, height: 1.8, style: 'art-nouveau', lightColor: '#ffe8f0', lightIntensity: 2.4 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.2, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.8 }
|
||||
),
|
||||
}
|
||||
),
|
||||
43: mk(
|
||||
'fauvist-studio',
|
||||
'Fauvist studio',
|
||||
'Bold painted walls · flooded with color and light',
|
||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
||||
{
|
||||
details: 'atelier',
|
||||
titleColor: '#402010',
|
||||
ambient: 0.7,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
44: mk(
|
||||
'expressionist-room',
|
||||
'Expressionist room',
|
||||
'Angular wood panels · dramatic raking light',
|
||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||
{
|
||||
details: 'atelier',
|
||||
titleColor: '#f0d8b0',
|
||||
ambient: 0.52,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
45: mk(
|
||||
'cubist-studio',
|
||||
'Cubist studio',
|
||||
'Paris atelier · factory windows · painted plaster',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
||||
{
|
||||
details: 'atelier',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
46: mk(
|
||||
'futurist-loft',
|
||||
'Futurist loft',
|
||||
'Steel and glass · industrial concrete · sweeping daylight',
|
||||
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.62,
|
||||
fog: '#181818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
|
||||
),
|
||||
doorWood: ['#606060', '#707070', '#505050'],
|
||||
}
|
||||
),
|
||||
47: mk(
|
||||
'suprematist-gallery',
|
||||
'Suprematist white cube',
|
||||
'Matte white paint · geometric light · polished floor',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
titleColor: '#222222',
|
||||
ambient: 0.72,
|
||||
fog: '#1a1a1a',
|
||||
background: '#111111',
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.5, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.5 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
trackLights: 0.4,
|
||||
doorWood: ['#aaaaaa', '#bbbbbb', '#999999'],
|
||||
}
|
||||
),
|
||||
48: mk(
|
||||
'constructivist-space',
|
||||
'Constructivist space',
|
||||
'Glass block · concrete · angular daylight',
|
||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.8, height: 2.0, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 4.0 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 2.0, height: 1.8, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 3.2 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
|
||||
),
|
||||
doorWood: ['#707070', '#808080', '#606060'],
|
||||
}
|
||||
),
|
||||
49: mk(
|
||||
'dada-salon',
|
||||
'Dada salon',
|
||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
||||
{
|
||||
details: 'salon',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
trackLights: 1.0,
|
||||
}
|
||||
),
|
||||
50: mk(
|
||||
'surrealist-interior',
|
||||
'Surrealist interior',
|
||||
'Dark painted walls · uncanny warm light',
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#ffe8c8', lightIntensity: 2.8 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.0, height: 1.2, style: 'sash', lightColor: '#c8e0ff', lightIntensity: 2.0 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 1.5, height: 1.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.85,
|
||||
}
|
||||
),
|
||||
51: mk(
|
||||
'abstract-expressionist-loft',
|
||||
'NYC loft',
|
||||
'Raw brick and painted plaster · north-light factory windows',
|
||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
52: mk(
|
||||
'pop-art-gallery',
|
||||
'Pop Art gallery',
|
||||
'White painted walls · fluorescent daylight · polished concrete',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
titleColor: '#222222',
|
||||
ambient: 0.75,
|
||||
warmLight: '#ffffff',
|
||||
fog: '#1a1a1a',
|
||||
background: '#101010',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
|
||||
),
|
||||
trackLights: 0.6,
|
||||
doorWood: ['#888888', '#999999', '#777777'],
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
function blendAccent(style: MovementInteriorStyle, accentHex?: string): MovementInteriorStyle {
|
||||
if (!accentHex) return style;
|
||||
const w = 0.08;
|
||||
const mix = (a: string, b: string) => {
|
||||
const pa = parseInt(a.replace('#', ''), 16);
|
||||
const pb = parseInt(b.replace('#', ''), 16);
|
||||
const r = Math.round(((pa >> 16) & 255) * w + ((pb >> 16) & 255) * (1 - w));
|
||||
const g = Math.round(((pa >> 8) & 255) * w + ((pb >> 8) & 255) * (1 - w));
|
||||
const bl = Math.round((pa & 255) * w + (pb & 255) * (1 - w));
|
||||
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
return {
|
||||
...style,
|
||||
tints: {
|
||||
...style.tints,
|
||||
wall: mix(accentHex, style.tints.wall),
|
||||
wallSide: style.tints.wallSide ? mix(accentHex, style.tints.wallSide) : undefined,
|
||||
trim: mix(accentHex, style.tints.trim),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Styles were authored with legacy ids 27–52; gallery_dev uses 1–26.
|
||||
* 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')) 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];
|
||||
}
|
||||
|
||||
export function resolveMovementInteriorStyle(
|
||||
movement: ArtMovement & { era_name?: string }
|
||||
): MovementInteriorStyle {
|
||||
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
|
||||
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
|
||||
return blendAccent(base, movement.color);
|
||||
}
|
||||
|
||||
/** @deprecated use surfaces.floor — kept for transitional imports */
|
||||
export type GalleryFloorKind = string;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_CONCURRENT = 6;
|
||||
const queue: Array<() => void> = [];
|
||||
let inFlight = 0;
|
||||
|
||||
function pumpQueue() {
|
||||
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueLoad(run: () => void) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const task = () => {
|
||||
inFlight++;
|
||||
run();
|
||||
resolve();
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
};
|
||||
queue.push(task);
|
||||
pumpQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits parallel image URL activation so hundreds of timeline portraits
|
||||
* do not saturate the browser connection pool at once.
|
||||
*/
|
||||
export function useQueuedImageSrc(src: string | null | undefined): string | undefined {
|
||||
const [activeSrc, setActiveSrc] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setActiveSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setActiveSrc(undefined);
|
||||
|
||||
enqueueLoad(() => {
|
||||
if (!cancelled) setActiveSrc(src);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return activeSrc;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||
|
||||
export function useTexturedMaterial(
|
||||
kind: SurfaceTextureKind,
|
||||
tint: string,
|
||||
spanW: number,
|
||||
spanH: number
|
||||
): THREE.MeshStandardMaterial {
|
||||
const material = useMemo(() => {
|
||||
const meters = getSurfaceTexture(kind).metersPerRepeat;
|
||||
const surf = cloneSurfaceTexture(
|
||||
kind,
|
||||
Math.max(1, spanW / meters),
|
||||
Math.max(1, spanH / meters)
|
||||
);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
map: surf.map,
|
||||
normalMap: surf.normalMap,
|
||||
normalScale: new THREE.Vector2(surf.normalScale, surf.normalScale),
|
||||
color: tint,
|
||||
roughness: surf.roughness,
|
||||
metalness: surf.metalness,
|
||||
// Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
|
||||
envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
|
||||
});
|
||||
}, [kind, tint, spanW, spanH]);
|
||||
|
||||
useEffect(() => () => material.dispose(), [material]);
|
||||
return material;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 enAudit from '../locales/en/audit.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';
|
||||
import ruAudit from '../locales/ru/audit.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', 'audit'],
|
||||
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,
|
||||
audit: enAudit,
|
||||
},
|
||||
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,
|
||||
audit: ruAudit,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Art history notes",
|
||||
"readSource": "Read source",
|
||||
"categoryTechnique": "Technique",
|
||||
"categoryComposition": "Composition",
|
||||
"categorySubject": "Subject",
|
||||
"categorySymbolism": "Symbolism",
|
||||
"categoryHistorical": "Historical context",
|
||||
"categoryOther": "Notes"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Curator activity",
|
||||
"back": "← Back",
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"loading": "Loading…",
|
||||
"empty": "No curator actions match these filters.",
|
||||
"databaseUnknown": "Reading the audit log for this environment’s database.",
|
||||
"databaseDev": "Dev database: {{name}}",
|
||||
"databaseProd": "Production database: {{name}}",
|
||||
"databaseNamed": "Database: {{name}}",
|
||||
"statTotal": "Total (filtered)",
|
||||
"stat24h": "Last 24 hours",
|
||||
"stat7d": "Last 7 days",
|
||||
"statNewest": "Most recent",
|
||||
"filterCurator": "Curator",
|
||||
"filterAction": "Action",
|
||||
"filterResource": "Resource type",
|
||||
"filterResourceId": "Resource id",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterSearch": "Search",
|
||||
"filterSearchPlaceholder": "Action, user, IP, details…",
|
||||
"allCurators": "All curators",
|
||||
"allActions": "All actions",
|
||||
"allResources": "All resources",
|
||||
"apply": "Apply filters",
|
||||
"clear": "Clear",
|
||||
"byCurator": "By curator",
|
||||
"byAction": "By action",
|
||||
"showing": "Showing {{count}} of {{total}}",
|
||||
"page": "Page {{page}} / {{pages}}",
|
||||
"colWhen": "Date & time",
|
||||
"colCurator": "Curator",
|
||||
"colAction": "Action",
|
||||
"colResource": "Resource",
|
||||
"colDetails": "Details",
|
||||
"colIp": "IP",
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Enter Gallery",
|
||||
"noBio": "No biography available yet.",
|
||||
"wikipediaSource": "Text adapted from Wikipedia",
|
||||
"backToTimeline": "← Back to Timeline"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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",
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic timeline →",
|
||||
"layoutVertical": "↑ Vertical timeline",
|
||||
"layoutTree": "🌳 Tree of art",
|
||||
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||
"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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Действия кураторов",
|
||||
"back": "← Назад",
|
||||
"loadFailed": "Не удалось загрузить журнал действий",
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Нет действий кураторов по этим фильтрам.",
|
||||
"databaseUnknown": "Журнал читается из базы данных текущего окружения.",
|
||||
"databaseDev": "База разработки: {{name}}",
|
||||
"databaseProd": "Продакшен-база: {{name}}",
|
||||
"databaseNamed": "База данных: {{name}}",
|
||||
"statTotal": "Всего (фильтр)",
|
||||
"stat24h": "За 24 часа",
|
||||
"stat7d": "За 7 дней",
|
||||
"statNewest": "Последнее",
|
||||
"filterCurator": "Куратор",
|
||||
"filterAction": "Действие",
|
||||
"filterResource": "Тип ресурса",
|
||||
"filterResourceId": "ID ресурса",
|
||||
"filterFrom": "С",
|
||||
"filterTo": "По",
|
||||
"filterSearch": "Поиск",
|
||||
"filterSearchPlaceholder": "Действие, пользователь, IP, детали…",
|
||||
"allCurators": "Все кураторы",
|
||||
"allActions": "Все действия",
|
||||
"allResources": "Все ресурсы",
|
||||
"apply": "Применить",
|
||||
"clear": "Сбросить",
|
||||
"byCurator": "По кураторам",
|
||||
"byAction": "По действиям",
|
||||
"showing": "Показано {{count}} из {{total}}",
|
||||
"page": "Стр. {{page}} / {{pages}}",
|
||||
"colWhen": "Дата и время",
|
||||
"colCurator": "Куратор",
|
||||
"colAction": "Действие",
|
||||
"colResource": "Ресурс",
|
||||
"colDetails": "Детали",
|
||||
"colIp": "IP",
|
||||
"prev": "Назад",
|
||||
"next": "Далее"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Войти в галерею",
|
||||
"noBio": "Биография пока недоступна.",
|
||||
"wikipediaSource": "Текст адаптирован из Википедии",
|
||||
"backToTimeline": "← На шкалу времени"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Загрузка…",
|
||||
"close": "Закрыть",
|
||||
"back": "Назад",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"error": "Что-то пошло не так",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Язык"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Проверено",
|
||||
"fixIt": "Исправить",
|
||||
"more": "Ещё",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить",
|
||||
"searching": "Поиск…",
|
||||
"saving": "Сохранение…",
|
||||
"loginTitle": "Вход куратора",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"loginFailed": "Ошибка входа"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"exitToTimeline": "Выход на шкалу времени",
|
||||
"loadingGallery": "Загрузка галереи…",
|
||||
"instructionsTitle": "Управление",
|
||||
"instructionMove": "Перетаскивайте для обзора",
|
||||
"instructionZoom": "Колёсико — масштаб",
|
||||
"instructionClick": "Нажмите на картину для подробностей",
|
||||
"wingOf": "Крыло {{current}} из {{total}}",
|
||||
"exitConfirmTitle": "Покинуть галерею?",
|
||||
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
|
||||
"stay": "Остаться",
|
||||
"exit": "Выход"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"layoutTree": "🌳 Древо искусства",
|
||||
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
}
|
||||
@@ -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}} рёбер"
|
||||
}
|
||||
@@ -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": "Не удалось сохранить заметки куратора."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user