Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
@@ -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`
|
||||
@@ -1,17 +1,29 @@
|
||||
# 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
|
||||
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
|
||||
DB_NAME=gallery_dev
|
||||
|
||||
# Production: LAN http://192.168.10.70:3520 and reverse-proxy at gallery.mysuperlab.netcraze.pro
|
||||
PORT=3520
|
||||
# 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=http://gallery.mysuperlab.netcraze.pro
|
||||
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)
|
||||
SESSION_SECRET=change-me-to-a-long-random-string
|
||||
SESSION_COOKIE_SECURE=false
|
||||
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=
|
||||
|
||||
@@ -4,6 +4,14 @@ 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/
|
||||
|
||||
# Recovery / temp files from local restore
|
||||
_extracted/
|
||||
|
||||
@@ -1,16 +1,133 @@
|
||||
# Art Gallery — REST API
|
||||
|
||||
Base URL:
|
||||
Base URL (paths are the same on every host; only the origin changes):
|
||||
|
||||
- **Production (public):** `http://gallery.mysuperlab.netcraze.pro`
|
||||
- **Production (LAN):** `http://192.168.10.70:3520`
|
||||
- **Development (direct):** `http://localhost:3520` or `http://localhost:3001` depending on `PORT` in `.env`
|
||||
- **Development (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). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes.
|
||||
|
||||
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 (curator session)**
|
||||
|
||||
```json
|
||||
{ "role": "curator", "username": "curator" }
|
||||
```
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
**Body:** `{ "username": "curator", "password": "…" }`
|
||||
|
||||
**Response:** `{ "role": "curator", "username": "curator" }`
|
||||
|
||||
**Errors:** `401` invalid credentials, `400` missing fields.
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Destroys the session cookie.
|
||||
|
||||
**Response:** `{ "ok": true }`
|
||||
|
||||
### Curator-only routes
|
||||
|
||||
These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session:
|
||||
|
||||
| Route | Audit action (mutations only) |
|
||||
|-------|-------------------------------|
|
||||
| `GET /api/paintings/checkup` | — (read) |
|
||||
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — |
|
||||
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
|
||||
| `GET /api/debug/image-proxy` | — |
|
||||
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
|
||||
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
|
||||
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
|
||||
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
|
||||
| `POST /api/paintings/:id/upload-image` | `painting.upload_image` |
|
||||
| `DELETE /api/paintings/:id` | `painting.delete` |
|
||||
| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` |
|
||||
| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` |
|
||||
| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` |
|
||||
|
||||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
||||
|
||||
Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)).
|
||||
|
||||
---
|
||||
|
||||
## `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`
|
||||
@@ -54,6 +171,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.
|
||||
@@ -65,9 +323,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`
|
||||
@@ -161,9 +422,9 @@ Each painting includes:
|
||||
| `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 import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run migrate:artist-checkup-flags` and `npm run migrate:artist-palette` on existing databases.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -300,7 +561,9 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
|
||||
|
||||
## `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. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
|
||||
|
||||
**Response**
|
||||
|
||||
@@ -320,7 +583,18 @@ 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,
|
||||
"has_influence_links": true
|
||||
},
|
||||
"influencedBy": [
|
||||
{
|
||||
"source_type": "painting",
|
||||
@@ -382,6 +656,8 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if
|
||||
|
||||
## Developer image audit
|
||||
|
||||
**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`
|
||||
@@ -478,6 +754,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
|
||||
}
|
||||
@@ -538,7 +816,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes
|
||||
}
|
||||
```
|
||||
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `image_cache_key`, `thumbnail_cache_key`, `fixed`, `checked`).
|
||||
|
||||
---
|
||||
|
||||
@@ -574,22 +852,29 @@ 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, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
|
||||
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
|
||||
| `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.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
# Art Gallery — database structure
|
||||
|
||||
PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model.
|
||||
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) |
|
||||
|
||||
@@ -67,14 +73,14 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
|
||||
| `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/`; nullable after debug **Clear** |
|
||||
| `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run fetch-artist-bios`) |
|
||||
| `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 import-painter-palette`) |
|
||||
| `palette_metadata` | JSONB | Enrichment from `Inputs/PainterPalette.csv` (`npm run dev:import-painter-palette`) |
|
||||
|
||||
Applied by `npm run migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`) and `npm run migrate:artist-palette` (`db/migrate-artist-palette.sql`).
|
||||
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`
|
||||
|
||||
@@ -108,7 +114,7 @@ Phases within an artist’s career (e.g. “Blue Period”, “Roman Period”).
|
||||
|
||||
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand.
|
||||
|
||||
Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
|
||||
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
|
||||
|
||||
### `painting_annotations`
|
||||
|
||||
@@ -128,7 +134,34 @@ Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
|
||||
| `sort_order` | INTEGER | Display order within the painting |
|
||||
| `confidence` | VARCHAR(20) | Default `curated`; Wikipedia pass uses `wikipedia` |
|
||||
|
||||
Applied by `npm run migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each work’s `wikipedia_title`).
|
||||
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) |
|
||||
|
||||
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`).
|
||||
|
||||
### `painting_influences`
|
||||
|
||||
@@ -148,7 +181,7 @@ Directed edges: *this painting* was influenced by *that painting*.
|
||||
|
||||
Unique constraint on `(painting_id, influenced_by_painting_id)`.
|
||||
|
||||
**Legacy mirror table.** `npm run 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.
|
||||
**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`
|
||||
|
||||
@@ -178,6 +211,51 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
|
||||
|
||||
**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`
|
||||
|
||||
Curator accounts (named logins). Anonymous site visitors do not have rows here.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | SERIAL PK | |
|
||||
| `username` | VARCHAR(64) UNIQUE | Login name |
|
||||
| `password_hash` | VARCHAR(255) | bcrypt hash |
|
||||
| `created_at` | TIMESTAMPTZ | |
|
||||
| `last_login_at` | TIMESTAMPTZ | Updated on successful login |
|
||||
|
||||
First curator is bootstrapped on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env.
|
||||
|
||||
### `curator_audit_log`
|
||||
|
||||
Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes).
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | BIGSERIAL PK | |
|
||||
| `user_id` | FK → `users` | Who performed the action |
|
||||
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` |
|
||||
| `resource_type` | VARCHAR(32) | `painting` or `artist` |
|
||||
| `resource_id` | INTEGER | Target row id |
|
||||
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
|
||||
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
|
||||
| `created_at` | TIMESTAMPTZ | |
|
||||
|
||||
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`.
|
||||
|
||||
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)`
|
||||
@@ -189,7 +267,7 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
|
||||
|
||||
## First-time setup
|
||||
|
||||
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 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;
|
||||
@@ -202,10 +280,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
|
||||
@@ -213,5 +291,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,363 @@
|
||||
# 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 C:\Users\SNAP\Nextcloud\Personal\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 C:\Users\SNAP\Nextcloud\Personal\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: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 debug mode / Checkup / Translations / **Influences**. Mutations are logged in `curator_audit_log` (view in pgAdmin).
|
||||
|
||||
**Roles:**
|
||||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
||||
| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), image fix/upload/delete APIs |
|
||||
|
||||
**Audit log (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 into **prod** (requires confirmation) |
|
||||
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
||||
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only |
|
||||
| `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
|
||||
SESSION_COOKIE_SECURE=false
|
||||
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` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md
|
||||
npm run harmonize:dry-run # preview DB + image changes only
|
||||
```
|
||||
|
||||
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,10 @@
|
||||
this file contains draft for future releases and features
|
||||
|
||||
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
|
||||
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
|
||||
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
|
||||
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
|
||||
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
|
||||
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
|
||||
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
|
||||
|
||||
@@ -8,10 +8,12 @@ The app is organised as a **drill-down hierarchy**:
|
||||
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||
3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only.
|
||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, side-wall hang, visit order left→right.
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||
|
||||
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
|
||||
|
||||
All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing).
|
||||
|
||||
## Stack
|
||||
@@ -31,7 +33,8 @@ Gallery/
|
||||
├── server/ # Express API, DB pool, image service
|
||||
├── client/ # React/Vite frontend
|
||||
│ ├── src/ # Source (components, pages, 3D scene)
|
||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (artist + movement modes)
|
||||
│ │ ├── 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
|
||||
@@ -44,8 +47,15 @@ Gallery/
|
||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── i18n/ # react-i18next bootstrap
|
||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
@@ -53,6 +63,10 @@ Gallery/
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ └── 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
|
||||
@@ -66,42 +80,59 @@ Gallery/
|
||||
├── Output/ # Generated exports (e.g. paintings.csv)
|
||||
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
||||
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
||||
├── server/migrate.js # npm run migrate — schema + incremental migrations
|
||||
├── deploy/ # Production nginx + systemd examples
|
||||
├── 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 start:prod # build client + serve on PORT (default 3520)
|
||||
# or: npm run build && npm run start
|
||||
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.
|
||||
|
||||
**Deployed URLs:** public http://gallery.mysuperlab.netcraze.pro · LAN http://192.168.10.70:3520 — see [setup.md](setup.md#production-deployment).
|
||||
### Production (TrueNAS Docker) — scheduled releases
|
||||
|
||||
### Development (two processes)
|
||||
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 PORT from .env (3520 production, 3001 typical dev)
|
||||
npm run dev:client # Vite on :5173, proxies /api and /images to PORT
|
||||
npm run prod:start # build client + serve on PORT from .env
|
||||
```
|
||||
|
||||
Use the Vite URL during frontend work for HMR.
|
||||
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 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| G
|
||||
D -->|Back to Gallery| G
|
||||
G -->|back door / Wings / Exit| H[Wing navigator]
|
||||
H -->|pick wing| G
|
||||
H -->|Exit to Timeline| A
|
||||
@@ -113,16 +144,28 @@ flowchart TD
|
||||
D -->|prev / next| D
|
||||
D -->|click centre image| F[Fullscreen lightbox]
|
||||
F -->|close| D
|
||||
D -->|Back| C
|
||||
D -->|Back to Gallery| C
|
||||
C -->|exit doorway / E key| E[Path picker]
|
||||
E -->|predecessors| C
|
||||
E -->|successors| C
|
||||
D -->|influence thumbnail| D
|
||||
D -->|artist link| B
|
||||
B -->|Back| A
|
||||
C -->|Back| A
|
||||
B -->|Back| C
|
||||
C -->|Back to Timeline| A
|
||||
D -->|Back to Timeline when opened from search| A
|
||||
```
|
||||
|
||||
### Back navigation
|
||||
|
||||
| Control | Behaviour |
|
||||
|---------|-----------|
|
||||
| **← Back to Timeline** (3D gallery header, movement **Exit to Timeline**) | Always returns to the **home timeline**: unmounts the hall, clears the gallery session, resets timeline zoom to the full catalog year range |
|
||||
| **← Back to Gallery** (painting detail from a hall) | Returns to the **same hall session** — camera position and wing are preserved |
|
||||
| **← Back to Timeline** (painting detail opened from catalog search) | Returns to the home timeline (same as gallery **Back to Timeline**) |
|
||||
| **← Back** (artist bio) | Returns to wherever you opened bio from (usually the artist hall) |
|
||||
|
||||
Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browser **Back** button; it is not wired to app navigation.
|
||||
|
||||
## Timeline and movement flow
|
||||
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
|
||||
@@ -132,12 +175,53 @@ The home page shows two linked views over the **same year window** (`viewStart`
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
|
||||
### Catalog search (timeline header)
|
||||
|
||||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||||
|
||||
| UX | Detail |
|
||||
|----|--------|
|
||||
| Minimum query | 2 characters after trim |
|
||||
| Debounce | 300 ms |
|
||||
| Result groups | Artists, Movements, Paintings (with thumb or movement colour swatch) |
|
||||
| Open artist | Preload images → artist 3D hall |
|
||||
| Open movement | Movement gallery (wing 1) |
|
||||
| Open painting | Painting detail with `returnTo: timeline` → **Back to Timeline** |
|
||||
| Keyboard | `↑`/`↓` highlight, `Enter` open, `Escape` close |
|
||||
|
||||
Run `npm run dev:migrate:search` once on existing databases before first use, or rely on `npm run dev:migrate` (includes `migrate-search.sql`). See [API.md — GET /api/search](API.md#get-apsearch).
|
||||
|
||||
### Timeline data loading
|
||||
|
||||
On first visit, `HomePage.tsx` fetches the full catalog once:
|
||||
|
||||
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…”** | Between clicking a portrait/movement and the 3D hall data being ready |
|
||||
| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) |
|
||||
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
|
||||
| Overlay **“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
|
||||
|
||||
| Input | Action |
|
||||
@@ -165,7 +249,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Artists on movement streams
|
||||
@@ -184,18 +268,22 @@ 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 **movement gallery** for that movement |
|
||||
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
|
||||
|
||||
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 two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological).
|
||||
The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
|
||||
|
||||
**Shared wall hang (all modes):** visit order fills the **left wall first**, then the **right**. 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. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
|
||||
|
||||
### Artist halls
|
||||
|
||||
@@ -204,10 +292,9 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
||||
| 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 the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) |
|
||||
| Wall order | Chronological visit order: first half on the **left** (entrance → back), second half on the **right** (back → entrance); first work left of the opening view, last on the right |
|
||||
| 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** |
|
||||
@@ -232,7 +319,7 @@ 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 **`painting_influence_sources`** (painting and artist sources). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend `art-influences-data.js`.
|
||||
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`.
|
||||
|
||||
### Movement galleries
|
||||
|
||||
@@ -243,7 +330,7 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|
||||
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
|
||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
|
||||
| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) |
|
||||
| Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) |
|
||||
| Frame captions | **Year · artist** label below each frame |
|
||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
@@ -254,7 +341,7 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|
||||
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
|
||||
| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** |
|
||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||
|
||||
**Controls (movement gallery):**
|
||||
|
||||
@@ -267,17 +354,34 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|
||||
|
||||
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 left-then-right rule as other halls |
|
||||
| Detail | Tour stop text panel; ‹ › walks tour stops |
|
||||
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
|
||||
|
||||
Full guide: [tours.md](tours.md).
|
||||
|
||||
### Shared 3D behaviour
|
||||
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering an **artist** hall to link disk files. Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
|
||||
|
||||
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
|
||||
|
||||
## Painting detail view
|
||||
|
||||
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another 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 (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`) |
|
||||
| **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 |
|
||||
|
||||
@@ -285,47 +389,75 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `‹` / `›` beside image | Previous / next work by the **same artist** (chronological order) |
|
||||
| `‹` / `›` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** |
|
||||
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
|
||||
| Click centre image | Open fullscreen lightbox |
|
||||
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
||||
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||||
| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Gallery** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Timeline** | Return to the home timeline (from search result, or from the 3D gallery header / movement **Exit to Timeline**) — hall unmounts, timeline zoom resets |
|
||||
| **About {artist}** | Open artist biography |
|
||||
|
||||
**Navigation rules:**
|
||||
|
||||
- **Catalog browsing** (‹ › / arrow keys) walks the current 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 and artist biography show a bottom-left panel with image search preview and action 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.
|
||||
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
|
||||
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
|
||||
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
|
||||
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress.
|
||||
- **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 account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations |
|
||||
|
||||
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
|
||||
|
||||
Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
|
||||
|
||||
## Developer tools (image audit)
|
||||
|
||||
Optional workflow for curating local image files — not part of the public visitor experience.
|
||||
Curator-only workflow for reviewing and fixing local image files — 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 and artist bio |
|
||||
| **Show more** | Home header checkbox (same util) | When debug mode is on, auto-opens the **More** modal on each painting / bio page load |
|
||||
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||||
| **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 debug tools |
|
||||
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
|
||||
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
|
||||
| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||||
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
|
||||
| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
|
||||
| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) |
|
||||
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
|
||||
| **Logout** | Home header (curators) | Ends session; hides debug tools |
|
||||
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
|
||||
|
||||
### Debug panel (painting detail and artist bio)
|
||||
@@ -335,13 +467,15 @@ When debug mode is on, a panel at the bottom-left shows the image search query,
|
||||
| Button | Painting detail | Artist bio |
|
||||
|--------|-----------------|------------|
|
||||
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags |
|
||||
| **Fix it** | Replaces local image from top search result | Replaces portrait |
|
||||
| **More** | Modal with up to **20** results (resolution shown when known) | Same |
|
||||
| **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 → disk + thumbnail | Local file → portrait |
|
||||
| **Upload** | Local file picker (`DebugUploadButton`) → full image + **auto-generated painting thumb**; full-page **Loading…** overlay; hides current image and pauses search/fix until upload finishes | Local file → portrait + **auto-generated portrait thumb**; same upload overlay behaviour |
|
||||
| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — |
|
||||
|
||||
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
|
||||
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.
|
||||
|
||||
@@ -353,15 +487,22 @@ Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so
|
||||
|
||||
**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`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run start` (or `npm run dev:server`) so new routes (e.g. clear, upload, delete painting, portrait debug, annotations) 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.
|
||||
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 |
|
||||
|
||||
@@ -12,8 +12,10 @@ How catalog content, biographies, and artwork files enter the system.
|
||||
|
||||
```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,43 +24,62 @@ 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 dev from prod:** `npm run prodto:dev:images`. **Bidirectional merge** (newer file wins): `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 |
|
||||
| `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 (mirrored into `painting_influence_sources` when you run `npm run migrate:influence-sources` and `npm run update-influences`).
|
||||
- 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** and painting detail panels via **`painting_influence_sources`** (see `GET /api/artists/:id/navigation` and `GET /api/paintings/:id` in [API.md](API.md)).
|
||||
|
||||
@@ -66,7 +87,7 @@ Artists are grouped by movement and century; the seed list targets at most ~100
|
||||
|
||||
## 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 +112,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,13 +132,52 @@ 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 alongside sources when running `npm run 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.
|
||||
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:
|
||||
|
||||
@@ -130,7 +190,7 @@ Influence data drives:
|
||||
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 audit-influence-duplicates
|
||||
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.
|
||||
@@ -138,19 +198,19 @@ Reports: edges present in both tables, duplicate rows within either table (shoul
|
||||
### 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:
|
||||
@@ -205,7 +265,7 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
||||
|
||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
|
||||
|
||||
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
|
||||
## Historical event markers (frontend timeline)
|
||||
|
||||
@@ -216,6 +276,8 @@ Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement)
|
||||
| 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.
|
||||
@@ -230,14 +292,14 @@ Short curator-style notes on the painting detail page — separate from the infl
|
||||
| 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 update-painting-annotations` (replaces existing rows per painting by default) |
|
||||
| Wikipedia pass | `npm run 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 |
|
||||
| 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 |
|
||||
|--------|--------|
|
||||
@@ -255,13 +317,13 @@ Categories include `subject`, `technique`, `context`, and `symbolism`. Sources c
|
||||
| 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.
|
||||
@@ -297,7 +359,7 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema
|
||||
|
||||
## 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` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artist’s** 3D hall so textures use files already on disk.
|
||||
|
||||
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
|
||||
|
||||
@@ -317,10 +379,10 @@ 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 via `npm run update-influences` / `art-influences-data.js` (writes both `painting_influence_sources` and legacy painting edges).
|
||||
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
|
||||
|
||||
@@ -329,8 +391,8 @@ These live in `client/public/` (and `client/dist/` after build).
|
||||
### One-time setup
|
||||
|
||||
```bash
|
||||
npm run migrate:artist-palette # adds artists.palette_metadata JSONB
|
||||
npm run import-painter-palette # enrich + influence links
|
||||
npm run dev:migrate:artist-palette # adds artists.palette_metadata JSONB
|
||||
npm run dev:import-painter-palette # enrich + influence links
|
||||
```
|
||||
|
||||
### What gets imported
|
||||
@@ -349,10 +411,10 @@ Name matching uses normalized strings plus aliases in `scripts/painter-palette-l
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run analyze-painter-palette # match report
|
||||
npm run import-painter-palette -- --dry-run
|
||||
npm run import-painter-palette -- --metadata-only
|
||||
npm run import-painter-palette -- --influences-only
|
||||
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.
|
||||
@@ -362,7 +424,7 @@ Re-run `import-painter-palette` after adding gallery artists or updating the CSV
|
||||
Export the full painting catalog as CSV:
|
||||
|
||||
```bash
|
||||
npm run export-paintings
|
||||
npm run dev:export-paintings
|
||||
```
|
||||
|
||||
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes.
|
||||
@@ -394,7 +456,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:
|
||||
@@ -419,9 +481,9 @@ When **Debug mode** is on (home header) or from the **Checkup** page:
|
||||
|
||||
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`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`.
|
||||
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/`.
|
||||
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**. The file picker uses a native `<label>` + hidden `<input>` (`DebugUploadButton.tsx`) so the `change` event is reliable on Windows/Chromium.
|
||||
6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog.
|
||||
|
||||
### Debug panel (painting detail and artist bio)
|
||||
@@ -431,14 +493,16 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
|
||||
| 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, 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 |
|
||||
| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline |
|
||||
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) |
|
||||
| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI |
|
||||
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** |
|
||||
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker (`DebugUploadButton`) → save full image + **auto-generated thumb**; centered **Loading…** overlay; clears preview and blocks search/fix while uploading |
|
||||
| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation |
|
||||
|
||||
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged.
|
||||
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session. Image URLs include `?v=<mtime_ms>` from API **`image_cache_key`** / **`thumbnail_cache_key`** (file `mtime` on disk) so replaced files reload after a full page refresh even when the relative path is unchanged. In-session counters still bump immediately after a mutation.
|
||||
|
||||
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
|
||||
|
||||
**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,391 @@
|
||||
# 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 (`C:\Users\SNAP\Nextcloud\Personal\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 tables are replaced by dev's contents:**
|
||||
|
||||
- `users` is overwritten. The **dev curator account and password become the prod login**. `curator_audit_log` is **not** synced — prod keeps its existing audit history.
|
||||
- The `session` table is truncated, so any active prod curator sessions are logged out.
|
||||
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
|
||||
|
||||
> **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,368 @@
|
||||
# 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 C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
|
||||
```
|
||||
|
||||
**Alternative (Node, postgres password in env):**
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
$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
|
||||
SESSION_COOKIE_SECURE=false
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
`npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty.
|
||||
|
||||
2. Run:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\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 C:\Users\SNAP\Nextcloud\Personal\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 C:\Users\SNAP\Nextcloud\Personal\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
|
||||
SESSION_COOKIE_SECURE=false
|
||||
```
|
||||
|
||||
Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
|
||||
|
||||
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 only |
|
||||
|
||||
## 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,173 @@
|
||||
# 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 harmonize config:
|
||||
|
||||
```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.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `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 |
|
||||
| `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`**
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,108 @@
|
||||
# Russian localization (i18n)
|
||||
|
||||
Full Russian support: **UI chrome** via `react-i18next`, **catalog text** via PostgreSQL `entity_translations`, with Cyrillic display aliases where available and English fallback.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | English | Russian |
|
||||
|-------|---------|---------|
|
||||
| UI labels, buttons | `client/src/locales/en/*.json` | `client/src/locales/ru/*.json` |
|
||||
| Names / titles (display) | canonical DB columns | `entity_translations` (`name`, `title`) |
|
||||
| Bios, notes, descriptions | canonical DB columns | `entity_translations` (`bio_full`, `body`, `notes`, …) |
|
||||
|
||||
English remains canonical in main tables. Russian rows use `status`: `draft` → `reviewed` → `published`. Public API returns only **`published`** (unless curator preview).
|
||||
|
||||
---
|
||||
|
||||
## User-facing locale switch
|
||||
|
||||
Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
||||
|
||||
- Persists `gallery_locale` in `localStorage`
|
||||
- Sets `document.documentElement.lang`
|
||||
- Passes `?locale=ru` on catalog API requests
|
||||
- Refetches bootstrap catalog when locale changes
|
||||
|
||||
---
|
||||
|
||||
## Setup (dev)
|
||||
|
||||
```powershell
|
||||
npm run dev:migrate # includes migrate-i18n.sql
|
||||
npm run dev:fetch-artist-bios-ru # draft bios + Cyrillic names from ru.wikipedia
|
||||
# Curator: Translations page → review → Publish
|
||||
npm run dev:import-translations -- --file path/to/translations.json --publish
|
||||
```
|
||||
|
||||
Optional manual import JSON:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "entity_type": "painting", "entity_id": 42, "field_name": "title", "value": "Джоконда", "status": "published", "source": "manual" }
|
||||
]
|
||||
```
|
||||
|
||||
CSV: `entity_type,entity_id,field_name,value,status,source`
|
||||
|
||||
---
|
||||
|
||||
## Curator translation review
|
||||
|
||||
1. Sign in as curator
|
||||
2. Header → **Translations** (or **Переводы** in RU UI)
|
||||
3. Pick entity type (artist / painting / movement)
|
||||
4. Select row → edit Russian fields side-by-side with English canonical
|
||||
5. **Publish** saves and marks rows `published`
|
||||
|
||||
Coverage stats show artists with `bio_full`, paintings with `title` alias, draft vs published counts.
|
||||
|
||||
API (curator-only): see [API.md](API.md#translations-curator).
|
||||
|
||||
---
|
||||
|
||||
## Translatable fields (v1)
|
||||
|
||||
| entity_type | fields |
|
||||
|-------------|--------|
|
||||
| `era`, `movement` | `name`, `description` |
|
||||
| `artist` | `name`, `bio_short`, `bio_full` |
|
||||
| `artist_period` | `name`, `description` |
|
||||
| `painting` | `title`, `description` |
|
||||
| `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,79 @@
|
||||
# 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 |
|
||||
|--------|-----------------|
|
||||
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` |
|
||||
| Story of Art | Same Title Case (+ chapter reference column) |
|
||||
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` |
|
||||
| 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`.
|
||||
|
||||
### 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).
|
||||
@@ -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,12 +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`; production uses `3520`) |
|
||||
| `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` | Optional public URL shown at startup (e.g. `http://gallery.mysuperlab.netcraze.pro`) |
|
||||
| `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` | `false` for local HTTP dev; `true` in prod behind HTTPS |
|
||||
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) |
|
||||
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty |
|
||||
|
||||
`.env` is git-ignored; never commit passwords.
|
||||
|
||||
Optional script tuning:
|
||||
|
||||
@@ -34,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
|
||||
@@ -48,147 +54,158 @@ 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 # db/schema.sql + db/migrate-*.sql via server/migrate.js
|
||||
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 migrate` is safe to re-run on existing databases (uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`).
|
||||
`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.
|
||||
|
||||
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 (paintings)
|
||||
npm run migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
|
||||
npm run migrate:painting-annotations # optional: art-history notes table
|
||||
npm run update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
|
||||
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 build` | Build production SPA into `client/dist` |
|
||||
| `npm run start` | API + static SPA on `HOST`:`PORT` |
|
||||
| `npm run start:prod` | Build client, then start server |
|
||||
| `npm run server` | Alias for `start` |
|
||||
| `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 DB to prod |
|
||||
| `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run prod:build` | Build production SPA into `client/dist` |
|
||||
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
|
||||
| `npm run prod:start` | Build client, then start server |
|
||||
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
|
||||
| `npm run dev:client` | Vite dev server on :5173 |
|
||||
| `npm run dev` | Alias for `start` |
|
||||
|
||||
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
|
||||
npm run build
|
||||
npm run prod:build
|
||||
# or: cd client && npm run build && cd ..
|
||||
npm run start
|
||||
npm run dev:start
|
||||
```
|
||||
|
||||
Open http://localhost:3520 (or your configured `HOST`/`PORT`).
|
||||
Open http://localhost:5173 (Vite) or http://localhost:3451 (API only).
|
||||
|
||||
## Production deployment
|
||||
|
||||
This install is intended to run at:
|
||||
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 |
|
||||
|--------|-----|
|
||||
| Public (reverse proxy) | http://gallery.mysuperlab.netcraze.pro |
|
||||
| LAN direct | http://192.168.10.70:3520 |
|
||||
| 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 |
|
||||
|
||||
### 1. Configure `.env`
|
||||
Quick deploy checklist:
|
||||
|
||||
Copy `.env.example` → `.env` and set at least:
|
||||
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
|
||||
|
||||
```env
|
||||
PORT=3520
|
||||
HOST=0.0.0.0
|
||||
PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro
|
||||
TRUST_PROXY=true
|
||||
```
|
||||
### Legacy deployment (optional)
|
||||
|
||||
`HOST=0.0.0.0` lets the app accept connections on the machine’s LAN IP (`192.168.10.70`). The client uses relative `/api` and `/images` paths, so no frontend URL changes are needed.
|
||||
|
||||
### 2. Build and start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd client && npm install && cd ..
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
Or use the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) (adjust `User`, `WorkingDirectory`, and `EnvironmentFile`).
|
||||
|
||||
### 3. Reverse proxy (public domain)
|
||||
|
||||
Point `gallery.mysuperlab.netcraze.pro` at the host running the app. Example nginx config: [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf).
|
||||
|
||||
The proxy forwards to `127.0.0.1:3520`. Keep `TRUST_PROXY=true` in `.env` so Express sees the correct client IP and scheme.
|
||||
|
||||
### 4. Firewall
|
||||
|
||||
Allow inbound **TCP 3520** on the gallery host if clients reach it directly on the LAN (`192.168.10.70:3520`). The public hostname only needs **80/443** on the reverse-proxy host.
|
||||
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 migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
|
||||
| `npm run migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
|
||||
| `npm run migrate:artist-palette` | `scripts/migrate-artist-palette.js` | Add `palette_metadata` JSONB on `artists` |
|
||||
| `npm run import-painter-palette` | `scripts/import-painter-palette.js` | Enrich artists + influence links from `Inputs/PainterPalette.csv` |
|
||||
| `npm run analyze-painter-palette` | `scripts/analyze-painter-palette.js` | Report CSV ↔ gallery artist name matches |
|
||||
| `npm run export-paintings` | `scripts/export-paintings-csv.js` | Write `Output/paintings.csv` (artist, painting, year) |
|
||||
| `npm run update-painting-annotations` | `scripts/update-painting-annotations.js` | Load curated notes from `painting-annotations-data.js` |
|
||||
| `npm run update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each work’s Wikipedia page |
|
||||
| `npm run update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) |
|
||||
| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
|
||||
| `npm run audit-influence-duplicates` | `scripts/audit-influence-duplicates.js` | Report mirrored legacy/sources edges and duplicate influence 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)
|
||||
@@ -203,8 +220,6 @@ These are checked in and maintained:
|
||||
- `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
|
||||
@@ -215,38 +230,62 @@ 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 influence edges for artist | `npm run update-influences`; check `painting_influence_sources` |
|
||||
| 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` |
|
||||
| Same work listed twice under *Influenced* | Stale server merging legacy + sources tables | Restart server; API reads `painting_influence_sources` only — run `npm run 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 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 |
|
||||
| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` |
|
||||
| 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 |
|
||||
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` |
|
||||
| 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 |
|
||||
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev` or `npm run dev:server` after pulling API changes |
|
||||
| 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 build` — panels use `object-fit: contain` for full image |
|
||||
| Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image |
|
||||
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate`, restart server |
|
||||
| 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 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,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.
|
||||
@@ -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"
|
||||
|
@@ -0,0 +1,415 @@
|
||||
artist,painting,link,size
|
||||
Albrecht Dürer,Abduction of a Woman,https://www.wga.hu/art/d/durer/2/11/1/10abduct.jpg,283 x 423 mm
|
||||
Albrecht Dürer,Adam,https://www.wga.hu/art/d/durer/1/06/2adam.jpg,209 x 81 cm
|
||||
Albrecht Dürer,Apostles Philip and James the Elder,https://www.wga.hu/art/d/durer/1/08/6apostl.jpg,46 x 37 cm
|
||||
Albrecht Dürer,Christ as the Man of Sorrows,https://www.wga.hu/art/d/durer/1/01/05sorrow.jpg,30 x 19 cm
|
||||
Albrecht Dürer,Courtyard of Innsbruck Castle With Clouds,https://www.wga.hu/art/d/durer/2/16/1/04courty.jpg,335 x 267 mm
|
||||
Albrecht Dürer,Courtyard of Innsbruck Castle Without Clouds,https://www.wga.hu/art/d/durer/2/16/1/03courty.jpg,335 x 267 mm
|
||||
Albrecht Dürer,Eve,https://www.wga.hu/art/d/durer/1/06/3eve.jpg,209 x 81 cm
|
||||
Albrecht Dürer,Fiesole Altarpiece,https://www.wga.hu/art/d/durer/1/06/7heller.jpg,190 x 260 cm
|
||||
Albrecht Dürer,Portrait of Barbara Holper,https://www.wga.hu/art/d/durer/1/01/01mother.jpg,47 x 38 cm
|
||||
Albrecht Dürer,Portrait of an unknown praying man,https://www.wga.hu/art/d/durer/2/11/5/11portra.jpg,365 x 258 mm
|
||||
Albrecht Dürer,Salvator Mundi with the Child Jesus,https://www.wga.hu/art/d/durer/1/05/02salvat.jpg,57 x 46 cm
|
||||
Albrecht Dürer,The Circumcision of Christ,https://www.wga.hu/art/d/durer/2/12/5virgin/10_life.jpg,
|
||||
Albrecht Dürer,The Death of Orpheus,https://www.wga.hu/art/d/durer/2/11/1/05orpheu.jpg,289 x 225 mm
|
||||
Albrecht Dürer,The Passion of Christ,https://www.wga.hu/art/d/durer/2/12/7smallp/2/14_small.jpg,
|
||||
Albrecht Dürer,Three Musicians,https://www.wga.hu/art/d/durer/1/04/1jabach2.jpg,94 x 51 cm
|
||||
Albrecht Dürer,Wire drawing mill near Nyremberg,https://www.wga.hu/art/d/durer/2/16/1/02mill.jpg,286 x 426 mm
|
||||
Alphonse Mucha,Gismonda,https://www.wga.hu/art/m/mucha/poster1.jpg,213 x 75 cm
|
||||
Andrea del Castagno,Last Supper,https://www.wga.hu/art/a/andrea/castagno/1_1440s/08lasts1.jpg,453 x 975 cm
|
||||
Andrei Rublev,Archangel Michael,https://www.wga.hu/art/r/rublyov/deesis2.jpg,158 x 106 cm
|
||||
Andrei Rublev,Holy Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Icon with the Eastern Orthodox Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Icon with the Old Testament Trinity The Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Angelica Kauffman,Self-Portrait of the Artist Hesitating Between the Arts of Music and Painting,https://www.wga.hu/art/k/kauffman/self_por.jpg,128 x 94 cm
|
||||
Angelica Kauffman,Venus Induces Helen to Fall in Love with Paris,https://www.wga.hu/art/k/kauffman/venushel.jpg,102 x 128 cm
|
||||
Arnold Böcklin,Isle of the Dead,https://www.wga.hu/art/b/bocklin/isle1.jpg,111 x 155 cm
|
||||
Artemisia Gentileschi,Judith Slaying Holofernes,https://www.wga.hu/art/g/gentiles/artemisi/judit.jpg,159 x 126 cm
|
||||
Artemisia Gentileschi,Judith and Her Maidservant,https://www.wga.hu/art/g/gentiles/artemisi/judith_m.jpg,114 x 93.5 cm
|
||||
Artemisia Gentileschi,Self-Portrait as the Allegory of Painting,https://www.wga.hu/art/g/gentiles/artemisi/selfport.jpg,"96,5 x 73,7 cm"
|
||||
Aubrey Beardsley,The Dancer s Reward,https://www.wga.hu/art/b/beardsle/beards04.jpg,342 x 272 mm
|
||||
Aubrey Beardsley,The Yellow Book,https://www.wga.hu/art/b/beardsle/beards07.jpg,
|
||||
Bronzino,Holy Family with St Anne and the Infant St John,https://www.wga.hu/art/b/bronzino/3/holyfam.jpg,127 x 102 cm
|
||||
Bronzino,Miracle of the Spring,https://www.wga.hu/art/b/bronzino/3a/05eleono.jpg,
|
||||
Bronzino,Portrait of Cosimo I de Medici in Armour,https://www.wga.hu/art/b/bronzino/1/cosimo1a.jpg,118 x 99 cm
|
||||
Bronzino,Portrait of Eleonora di Toledo with her son Giovanni,https://www.wga.hu/art/b/bronzino/1/eleonorb.jpg,59 x 46 cm
|
||||
Bronzino,Venus Cupid Folly and Time,https://www.wga.hu/art/b/bronzino/4/venus_cu.jpg,147 x 117 cm
|
||||
Camille Pissarro,Boulevard Montmartre,https://www.wga.hu/art/p/pissarro/camille/2/paris03.jpg,53 x 65 cm
|
||||
Camille Pissarro,Jalais Hill Pontoise,https://www.wga.hu/art/p/pissarro/camille/1/ponto01.jpg,89 x 116 cm
|
||||
Camille Pissarro,Lordship Lane Station,https://www.wga.hu/art/p/pissarro/camille/4/variou2.jpg,45 x 73 cm
|
||||
Camille Pissarro,Red Roofs,https://www.wga.hu/art/p/pissarro/camille/3/winter15.jpg,54 x 65 cm
|
||||
Caravaggio,Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,David with the Head of Goliath,https://www.wga.hu/art/c/caravagg/08/52david.jpg,91 x 116 cm
|
||||
Caravaggio,Madonna di Crevole,https://www.wga.hu/art/c/caravagg/07/42loreto.jpg,260 x 150 cm
|
||||
Caravaggio,Mary Magdalene,https://www.wga.hu/art/c/caravagg/02/12magda.jpg,123 x 99 cm
|
||||
Caravaggio,Saint John the Baptist,https://www.wga.hu/art/c/caravagg/10/62behead.jpg,361 x 520 cm
|
||||
Caravaggio,Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,Saint Thomas,https://www.wga.hu/art/c/caravagg/06/34thomas.jpg,107 x 146 cm
|
||||
Caravaggio,The Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,The Crucifixion of Saint Peter,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
|
||||
Caravaggio,The Crucifixion of Saint Peter and the Decapitation of Saint John the Baptist,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
|
||||
Caspar David Friedrich,Wanderer above the Sea of Fog,https://www.wga.hu/art/f/friedric/2/209fried.jpg,95 x 75 cm
|
||||
Cimabue,Madonna Enthroned with the Child St Francis and Four Angels,https://www.wga.hu/art/c/cimabue/madonna/madonn_1.jpg,320 x 340 cm
|
||||
Cimabue,Madonna and Child Enthroned with Angels and Prophets Santa Trinit Maest,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
|
||||
Cimabue,Santa Trinita Maestà,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
|
||||
Claude Lorrain,Embarkation of the Queen of Sheba,https://www.wga.hu/art/c/claude/2/10sheba.jpg,148 x 194 cm
|
||||
Claude Monet,"Impression, Sunrise",https://www.wga.hu/art/m/monet/03/0impress.jpg,48 x 63 cm
|
||||
Claude Monet,Rouen Cathedral,https://www.wga.hu/art/m/monet/07/5cathed3.jpg,100 x 65 cm
|
||||
Claude Monet,The Japanese Bridge,https://www.wga.hu/art/m/monet/08/3bridge5.jpg,89 x 116 cm
|
||||
Claude Monet,The Lunch,https://www.wga.hu/art/m/monet/02/1vario02.jpg,230 x 150 cm
|
||||
Claude Monet,Water Lilies,https://www.wga.hu/art/m/monet/08/7liliy01.jpg,90 x 100 cm
|
||||
Diego Velázquez,Christ in the House of Martha and Mary,https://www.wga.hu/art/v/velazque/01/0108vela.jpg,"60 x 103,5 cm"
|
||||
Diego Velázquez,Kitchen Scene,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
|
||||
Diego Velázquez,Kitchen Scene with the Supper in Emmaus,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
|
||||
Diego Velázquez,Las Meninas,https://www.wga.hu/art/v/velazque/08/0801vela.jpg,318 x 276 cm
|
||||
Diego Velázquez,Philip IV of Spain 1605 1665,https://www.wga.hu/art/v/velazque/02/0204vela.jpg,210 x 102 cm
|
||||
Diego Velázquez,Portrait du Conde-Duque de Olivares,https://www.wga.hu/art/v/velazque/05/0508vela.jpg,314 x 240 cm
|
||||
Diego Velázquez,Portrait of Philip IV of Spain,https://www.wga.hu/art/v/velazque/10/1002vela.jpg,"47 x 37,5 cm"
|
||||
Diego Velázquez,Queen Mariana of Spain 1634 1696,https://www.wga.hu/art/v/velazque/10/1003vela.jpg,231 x 131 cm
|
||||
Diego Velázquez,The Buffoon Pablo de Valladolid,https://www.wga.hu/art/v/velazque/06/0604vela.jpg,214 x 125 cm
|
||||
Diego Velázquez,The Dwarf Francisco Lezcano,https://www.wga.hu/art/v/velazque/07/0702vela.jpg,107 x 83 cm
|
||||
Domenico Ghirlandaio,Birth of the Virgin,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/2birth.jpg,
|
||||
Domenico Ghirlandaio,Confirmation of the Rule,https://www.wga.hu/art/g/ghirland/domenico/5sassett/frescoes/5confir.jpg,
|
||||
Domenico Ghirlandaio,Expulsion of Joachim from the Temple,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/1expuls.jpg,
|
||||
Domenico Ghirlandaio,Madonna of Mercy and Lamentation,https://www.wga.hu/art/g/ghirland/domenico/1early/2ogniss.jpg,
|
||||
Domenico Ghirlandaio,Marriage of Mary,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/4marria.jpg,
|
||||
Domenico Ghirlandaio,Portrait of Giovanna Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
|
||||
Domenico Ghirlandaio,Portrait of Lucrezia Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
|
||||
Domenico Ghirlandaio,Saint John the Evangelist on the Island of Patmos,https://www.wga.hu/art/g/ghirland/domenico/7panel/02patmos.jpg,
|
||||
Duccio,Christ Accused by the Pharisees,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso12.jpg,49 x 57 cm
|
||||
Duccio,Christ and the Samaritan Woman,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_6.jpg,44 x 46 cm
|
||||
Duccio,Disputation with the Doctors,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_7.jpg,43 x 43 cm
|
||||
Duccio,Entry into Jerusalem,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso01.jpg,100 x 57 cm
|
||||
Duccio,Gualino Madonna,https://www.wga.hu/art/d/duccio/various/22gualin.jpg,157 x 86 cm
|
||||
Duccio,Jesus Appears on Lake Tiberias,https://www.wga.hu/art/d/duccio/maesta/crown_v/cro_v_3.jpg,37 x 48 cm
|
||||
Duccio,Madonna with Child and six Angels,https://www.wga.hu/art/d/duccio/various/6perugi.jpg,97 x 63 cm
|
||||
Duccio,Maest - Backside,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
|
||||
Duccio,Maestà,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
|
||||
Duccio,Pact of Judas top Christ Taking Leave of the Apostles bottom,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso04.jpg,50 x 53 cm
|
||||
Duccio,Rucellai Madonna,https://www.wga.hu/art/d/duccio/ruccelai/3ruccela.jpg,450 x 290 cm
|
||||
Duccio,The Calling of the Apostles Peter and Andrew,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_4.jpg,44 x 46 cm
|
||||
Duccio,The Healing of the Man Born Blind,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_7.jpg,45 x 47 cm
|
||||
Duccio,The Madonna of the Franciscans,https://www.wga.hu/art/d/duccio/various/51franci.jpg,"23,5 x 16 cm"
|
||||
Duccio,The Nativity with the Prophets Isaiah and Ezekiel,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_1d.jpg,48 x 87 cm
|
||||
Duccio,The Temptation of Christ on the Mountain,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_3.jpg,43 x 46 cm
|
||||
Duccio,The Wedding at Cana,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_5.jpg,44 x 47 cm
|
||||
Duccio,The three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Duccio,Three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Duccio,Three Marys at the Tomb top Descent into Hell bottom,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Edgar Degas,Blue Dancer,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
|
||||
Edgar Degas,Blue Dancers,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
|
||||
Edgar Degas,The Ballet Class,https://www.wga.hu/art/d/degas/4/1880s_03.jpg,82 x 77 cm
|
||||
Edvard Munch,Anxiety,https://www.wga.hu/art/m/munch/scream5.jpg,94 x 74 cm
|
||||
Edvard Munch,Madonna,https://www.wga.hu/art/m/munch/madonna1.jpg,91 x 71 cm
|
||||
Edvard Munch,The Dance of Life,https://www.wga.hu/art/m/munch/stages2.jpg,125 x 191 cm
|
||||
Edvard Munch,The Scream,https://www.wga.hu/art/m/munch/scream1.jpg,91 x 74 cm
|
||||
Edvard Munch,The Sick Child,https://www.wga.hu/art/m/munch/sick1.jpg,120 x 119 cm
|
||||
El Greco,Disrobement of Christ,https://www.wga.hu/art/g/greco_el/04/0401grec.jpg,
|
||||
El Greco,Dormition of the Virgin,https://www.wga.hu/art/g/greco_el/01/0101grec.jpg,"61,4 x 45 cm"
|
||||
El Greco,Ecstasy of St Francis,https://www.wga.hu/art/g/greco_el/06/0610grec.jpg,102 x 75 cm
|
||||
El Greco,Miracle of Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Saint Francis reveives the stigmata,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
|
||||
El Greco,Saint Joseph,https://www.wga.hu/art/g/greco_el/06/0613grec.jpg,68 x 56 cm
|
||||
El Greco,Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Saint Peter Weeping,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Studies for Martyrdom of Saint Peter the Martyr,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,The Penitent Saint Jerome,https://www.wga.hu/art/g/greco_el/21/2104grec.jpg,166 x 110 cm
|
||||
El Greco,The Stigmata of Saint Francis,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
|
||||
El Greco,View of Toledo,https://www.wga.hu/art/g/greco_el/11/1104grec.jpg,121 x 109 cm
|
||||
El Greco,the repose of the Virgin Mary,https://www.wga.hu/art/g/greco_el/10/1010grec.jpg,52 x 36 cm
|
||||
Eugène Delacroix,Arab Horses Fighting in a Stable,https://www.wga.hu/art/d/delacroi/5/512delac.jpg,"64,6 x 81 cm"
|
||||
Eugène Delacroix,Liberty Leading the People,https://www.wga.hu/art/d/delacroi/2/208delac.jpg,260 x 325 cm
|
||||
Eugène Delacroix,The Death of Sardanapalus,https://www.wga.hu/art/d/delacroi/2/204delac.jpg,392 x 496 cm
|
||||
Eugène Delacroix,The Massacre at Chios,https://www.wga.hu/art/d/delacroi/1/107delac.jpg,419 x 354 cm
|
||||
Eugène Delacroix,Women of Algiers in their Apartment,https://www.wga.hu/art/d/delacroi/3/307delac.jpg,180 x 229 cm
|
||||
Fra Angelico,Apparition at Arles,https://www.wga.hu/art/a/angelico/12/02compa4.jpg,26 x 31 cm
|
||||
Fra Angelico,Birth of the Virgin,https://www.wga.hu/art/a/angelico/04/2edge1.jpg,23 x 14 cm
|
||||
Fra Angelico,Compagnia di San Francesco altarpiece,https://www.wga.hu/art/a/angelico/12/01compag.jpg,189 x 81 cm
|
||||
Fra Angelico,Deposition from the Cross,https://www.wga.hu/art/a/angelico/08/1trinita.jpg,176 x 185 cm
|
||||
Fra Angelico,Saint Francis and a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
|
||||
Fra Angelico,Sant Ambrogio Altarpiece,https://www.wga.hu/art/a/angelico/07/altar_sm.jpg,220 x 227 cm
|
||||
Fra Angelico,The Annunciation,https://www.wga.hu/art/a/angelico/09/cells/03_annun.jpg,176 x 148 cm
|
||||
Fra Angelico,The Funeral of a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
|
||||
Fra Angelico,The Meeting of Saint Dominic and Saint Francis of Assisi,https://www.wga.hu/art/a/angelico/13/03prede4.jpg,27 x 26 cm
|
||||
Francisco Goya,Dream of St Gregory,https://www.wga.hu/art/g/goya/4/403goya.jpg,188 x 113 cm
|
||||
Francisco Goya,Portrait of Andr Derain,https://www.wga.hu/art/g/goya/4/407goya.jpg,"95 x 65,7 cm"
|
||||
Francisco Goya,Portrait of the Duke of Wellington,https://www.wga.hu/art/g/goya/7/707goya.jpg,64 x 52 cm
|
||||
Francisco Goya,The Clothed Maja,https://www.wga.hu/art/g/goya/5/502goya.jpg,97 x 190 cm
|
||||
Francisco Goya,The Naked Maja,https://www.wga.hu/art/g/goya/5/501goya.jpg,97 x 190 cm
|
||||
Francisco Goya,The Third of May 1808,https://www.wga.hu/art/g/goya/7/714goya.jpg,266 x 345 cm
|
||||
Francisco Goya,Witches Sabbath,https://www.wga.hu/art/g/goya/2/218goya.jpg,43 x 30 cm
|
||||
François Boucher,Madame de Pompadour,https://www.wga.hu/art/b/boucher/2/pompado2.jpg,"72,5 x 57 cm"
|
||||
François Boucher,The Blonde Odalisque,https://www.wga.hu/art/b/boucher/2/o_murph.jpg,59 x 73 cm
|
||||
François Boucher,The Toilet of Venus,https://www.wga.hu/art/b/boucher/2/venus_to.jpg,108 x 85 cm
|
||||
Georges Seurat,Bathers at Asni res,https://www.wga.hu/art/s/seurat/figures/bathers1.jpg,201 x 300 cm
|
||||
Georges Seurat,Models,https://www.wga.hu/art/s/seurat/figures/models.jpg,39 x 49 cm
|
||||
Georges Seurat,Sunday Afternoon on the Island of La Grande Jatte,https://www.wga.hu/art/s/seurat/figures/jatte.jpg,208 x 308 cm
|
||||
Georges Seurat,The Circus,https://www.wga.hu/art/s/seurat/figures/zcircus1.jpg,180 x 148 cm
|
||||
Georges Seurat,The Lighthouse at Honfleur,https://www.wga.hu/art/s/seurat/landscap/honfleu3.jpg,67 x 82 cm
|
||||
Georges Seurat,Young Woman Powdering Herself,https://www.wga.hu/art/s/seurat/figures/powderin.jpg,96 x 80 cm
|
||||
Giorgione,A Woman Spinning and an Old Woman,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
|
||||
Giorgione,Old Woman Cooking Eggs,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
|
||||
Giorgione,Sleeping Venus,https://www.wga.hu/art/g/giorgion/various/venus.jpg,108 x 175 cm
|
||||
Giorgione,The Pastoral Concert,https://www.wga.hu/art/g/giorgion/various/concert.jpg,110 x 138 cm
|
||||
Giotto,Apparition to Fra Agostino and to Bishop Guido of Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc21.jpg,270 x 230 cm
|
||||
Giotto,Badia Polyptych,https://www.wga.hu/art/g/giotto/z_panel/2panel/5badia.jpg,91x334 cm
|
||||
Giotto,Canonization of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc24.jpg,270 x 230 cm
|
||||
Giotto,Carnesecchi Triptych,https://www.wga.hu/art/g/giotto/z_panel/4stefane/10stefan.jpg,220 x 245 cm
|
||||
Giotto,Death and Ascension of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc20.jpg,270 x 230 cm
|
||||
Giotto,Death of the Knight of Celano,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc16.jpg,270 x 230 cm
|
||||
Giotto,Descent into Hell,https://www.wga.hu/art/g/giotto/z_panel/3polypty/6limbo.jpg,45 x 44 cm
|
||||
Giotto,Dream of Innocent III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc06.jpg,270 x 230 cm
|
||||
Giotto,Dream of the Palace,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc03.jpg,270 x 230 cm
|
||||
Giotto,Exorcism of the Demons at Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc10.jpg,270 x 230 cm
|
||||
Giotto,Homage of a Simple Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc01.jpg,270 x 230 cm
|
||||
Giotto,Institution of the Crib at Greccio,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc13.jpg,270 x 230 cm
|
||||
Giotto,Joachim s Dream,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi5.jpg,200 x 185 cm
|
||||
Giotto,Joachim s Sacrificial Offering,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi4.jpg,200 x 185 cm
|
||||
Giotto,Miracle of the Crucifix,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc04.jpg,270 x 230 cm
|
||||
Giotto,Renunciation of Wordly Goods,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc05.jpg,270 x 230 cm
|
||||
Giotto,Saint Francis,https://www.wga.hu/art/g/giotto/assisi/upper/05saints.jpg,
|
||||
Giotto,Scenes from the New Testament Lamentation,https://www.wga.hu/art/g/giotto/assisi/upper/new_test/05lamen.jpg,300 x 300 cm
|
||||
Giotto,Sermon to the Birds,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc15.jpg,270 x 200 cm
|
||||
Giotto,St Francis Giving his Mantle to a Poor Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc02.jpg,270 x 230 cm
|
||||
Giotto,St Francis Mourned by St Clare,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc23.jpg,270 x 230 cm
|
||||
Giotto,St Francis Preaching before Honorius III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc17.jpg,270 x 230 cm
|
||||
Giotto,St Francis before the Sultan,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc11.jpg,270 x 230 cm
|
||||
Giotto,Verification of the Stigmata,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc22.jpg,270 x 230 cm
|
||||
Giotto,Vision of the Flaming Chariot,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc08.jpg,270 x 230 cm
|
||||
Giotto,the seven virtues,https://www.wga.hu/art/g/giotto/padova/7vicevir/virtu_1.jpg,120 x 60 cm
|
||||
Gustav Klimt,Portrait of Adele Bloch-Bauer I,https://www.wga.hu/art/k/klimt/2/33portrait.jpg,140 x 140 cm
|
||||
Gustav Klimt,Stenberg Brothers Exhibition Poster,https://www.wga.hu/art/k/klimt/2/21poster.jpg,970 x 700 mm
|
||||
Gustav Klimt,The Kiss,https://www.wga.hu/art/k/klimt/2/24kiss.jpg,180 x 180 cm
|
||||
Gustav Klimt,The Tree of Life,https://www.wga.hu/art/k/klimt/2/42stoclet.jpg,200 x 102 cm
|
||||
Gustave Courbet,A Burial at Ornans,https://www.wga.hu/art/c/courbet/1/courb104.jpg,315 x 668 cm
|
||||
Gustave Courbet,Lot and his daughters,https://www.wga.hu/art/c/courbet/1/courb101.jpg,89 x 116 cm
|
||||
Gustave Courbet,Man with a Pipe,https://www.wga.hu/art/c/courbet/2/courb223.jpg,56 x 47 cm
|
||||
Gustave Courbet,Sleep,https://www.wga.hu/art/c/courbet/3/courb303.jpg,135 x 200 cm
|
||||
Gustave Courbet,The Origin of the World,https://www.wga.hu/art/c/courbet/3/courb304.jpg,46 x 55 cm
|
||||
Henri de Toulouse-Lautrec,At the Moulin Rouge,https://www.wga.hu/art/t/toulouse/3/2mouli04.jpg,80 x 60 cm
|
||||
Henri de Toulouse-Lautrec,In Bed The Kiss,https://www.wga.hu/art/t/toulouse/3/3bed2.jpg,53 x 34 cm
|
||||
Henri de Toulouse-Lautrec,Jane Avril,https://www.wga.hu/art/t/toulouse/6/litho25.jpg,56 x 34 cm
|
||||
Henri de Toulouse-Lautrec,Moulin Rouge La Goulue,https://www.wga.hu/art/t/toulouse/6/litho01.jpg,191 x 117 cm
|
||||
Henri de Toulouse-Lautrec,Portrait of Vincent van Gogh,https://www.wga.hu/art/t/toulouse/2/1misc01.jpg,54 x 45 cm
|
||||
Henri de Toulouse-Lautrec,Yvette Guilbert,https://www.wga.hu/art/t/toulouse/4/cabare17.jpg,56 x 48 cm
|
||||
Hieronymus Bosch,Christ Child with a Walking Frame,https://www.wga.hu/art/b/bosch/1early/13reverse.jpg,
|
||||
Hieronymus Bosch,Crucifixion with a Donor,https://www.wga.hu/art/b/bosch/1early/08crucif.jpg,"74,7 x 61 cm"
|
||||
Hieronymus Bosch,Death and the Miser,https://www.wga.hu/art/b/bosch/5panels/10deathm.jpg,93 x 31 cm
|
||||
Hieronymus Bosch,Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
|
||||
Hieronymus Bosch,Head of a Halberdier,https://www.wga.hu/art/b/bosch/1early/02fragme.jpg,28 x 20 cm
|
||||
Hieronymus Bosch,The Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
|
||||
Hieronymus Bosch,Two Male Heads,https://www.wga.hu/art/b/bosch/1early/03heads.jpg,"14,5 x 12 cm"
|
||||
Hieronymus Bosch,Two monsters,https://www.wga.hu/art/b/bosch/93graph/28graphi.jpg,164 x 116 mm
|
||||
Hieronymus Bosch,Visions of the Hereafter Fall of the Damned into Hell,https://www.wga.hu/art/b/bosch/6venice/2hell1.jpg,"86,5 x 39,5 cm"
|
||||
Honoré Daumier,Rue Transnonain,https://www.wga.hu/art/d/daumier/11transn.jpg,290 x 445 mm
|
||||
Honoré Daumier,The Uprising,https://www.wga.hu/art/d/daumier/08uprisi.jpg,88 x 113 cm
|
||||
Honoré Daumier,Third-Class Carriage,https://www.wga.hu/art/d/daumier/093rdcla.jpg,65 x 90 cm
|
||||
J. M. W. Turner,Dido Building Carthage,https://www.wga.hu/art/t/turner/1/107turnd.jpg,156 x 230 cm
|
||||
J. M. W. Turner,Rain Steam and Speed,https://www.wga.hu/art/t/turner/2/205turne.jpg,91 x 122 cm
|
||||
J. M. W. Turner,Snow Storm Steam-Boat off a Harbour s Mouth,https://www.wga.hu/art/t/turner/2/2040turn.jpg,91 x 122 cm
|
||||
J. M. W. Turner,The Fighting Temeraire,https://www.wga.hu/art/t/turner/2/201turne.jpg,91 x 122 cm
|
||||
J. M. W. Turner,Ulysses Deriding Polyphemus,https://www.wga.hu/art/t/turner/2/209turne.jpg,132 x 203 cm
|
||||
Jacques-Louis David,Leonidas at Thermopylae,https://www.wga.hu/art/d/david_j/4/414david.jpg,395 x 531 cm
|
||||
Jacques-Louis David,Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
|
||||
Jacques-Louis David,Portrait of the Artist Holding a Thistle,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
|
||||
Jacques-Louis David,Portrait of the artist s father,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
|
||||
Jacques-Louis David,The Death of Marat,https://www.wga.hu/art/d/david_j/3/301david.jpg,162 x 128 cm
|
||||
Jacques-Louis David,The Intervention of the Sabine Women,https://www.wga.hu/art/d/david_j/3/311david.jpg,385 x 522 cm
|
||||
Jacques-Louis David,The Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
|
||||
Jan van Eyck,A Young Man in a scarlet turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
|
||||
Jan van Eyck,Adoration of the Lamb from the Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l3adora.jpg,"137,7 x 242,3 cm"
|
||||
Jan van Eyck,Arnolfini Portrait,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
|
||||
Jan van Eyck,Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
|
||||
Jan van Eyck,Madonna Enthroned,https://www.wga.hu/art/e/eyck_van/jan/02page/23suckli.jpg,"65,5 x 49,5 cm"
|
||||
Jan van Eyck,Madonna at the Fountain,https://www.wga.hu/art/e/eyck_van/jan/02page/29founta.jpg,19 x 12 cm
|
||||
Jan van Eyck,Madonna with Canon Joris van der Paele,https://www.wga.hu/art/e/eyck_van/jan/21paele/21paele.jpg,122 x 157 cm
|
||||
Jan van Eyck,Man in a Red Turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
|
||||
Jan van Eyck,Portrait of Baudouin de Lannoy,https://www.wga.hu/art/e/eyck_van/jan/02page/19lannoy.jpg,26 x 20 cm
|
||||
Jan van Eyck,Portrait of Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/111alber.jpg,212 x 180 mm
|
||||
Jan van Eyck,Portrait of Giovanni di Nicolao Arnolfini,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
|
||||
Jan van Eyck,Portrait of Jan de Leeuw,https://www.wga.hu/art/e/eyck_van/jan/02page/22leeuw.jpg,"24,5 x 19 cm"
|
||||
Jan van Eyck,Portrait of Margareta van Eyck,https://www.wga.hu/art/e/eyck_van/jan/02page/28margar.jpg,"32,6 x 25,8 cm"
|
||||
Jan van Eyck,Portrait of a Man with Carnation,https://www.wga.hu/art/e/eyck_van/jan/02page/17carnat.jpg,40 x 31 cm
|
||||
Jan van Eyck,Prophet Zacharias Angel of The Annunciation,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed1/u1annun.jpg,"164,8 x 71,7 cm"
|
||||
Jan van Eyck,Singing Angels,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open2/u2singi.jpg,"164,5 x 71,5 cm"
|
||||
Jan van Eyck,Study for Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/11alberg.jpg,"34,1 x 27,3 cm"
|
||||
Jan van Eyck,The Ghent Altarpiece The Donor,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed2/l1donor.jpg,"149,1 x 54,1 cm"
|
||||
Jan van Eyck,The Ghent Altarpiece wings closed,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
|
||||
Jan van Eyck,The Just Judges,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l1judge.jpg,145 x 51 cm
|
||||
Jan van Eyck,The Madonna in the Church,https://www.wga.hu/art/e/eyck_van/jan/01page/04church.jpg,32 x 14 cm
|
||||
Jean-Antoine Watteau,F tes Venitiennes,https://www.wga.hu/art/w/watteau/antoine/2/15fetesv.jpg,56 x 46 cm
|
||||
Jean-Antoine Watteau,Gilles,https://www.wga.hu/art/w/watteau/antoine/2/17gilles.jpg,"184,5 x 149,5 cm"
|
||||
Jean-Antoine Watteau,Mezzetin,https://www.wga.hu/art/w/watteau/antoine/2/141mezze.jpg,"55,2 x 43,2 cm"
|
||||
Jean-Antoine Watteau,Pilgrimage to Cythera,https://www.wga.hu/art/w/watteau/antoine/1/08cythe2.jpg,129 x 194 cm
|
||||
Jean-Antoine Watteau,The Dance,https://www.wga.hu/art/w/watteau/antoine/2/19dance.jpg,97 x 116 cm
|
||||
Jean-Auguste-Dominique Ingres,Grande Odalisque,https://www.wga.hu/art/i/ingres/05ingres.jpg,91 x 162 cm
|
||||
Jean-Auguste-Dominique Ingres,Jupiter and Thetis,https://www.wga.hu/art/i/ingres/03ingrex.jpg,320 x 260 cm
|
||||
Jean-Auguste-Dominique Ingres,Napoleon on His Imperial Throne,https://www.wga.hu/art/i/ingres/010ingre.jpg,259 x 162 cm
|
||||
Jean-Auguste-Dominique Ingres,Oedipus and the Sphinx,https://www.wga.hu/art/i/ingres/071ingre.jpg,189 x 144 cm
|
||||
Jean-Auguste-Dominique Ingres,The Turkish Bath,https://www.wga.hu/art/i/ingres/17ingres.jpg,
|
||||
Jean-Baptiste-Camille Corot,Bust of a Young Woman,https://www.wga.hu/art/c/corot/corot07.jpg,53 x 40 cm
|
||||
Jean-Baptiste-Camille Corot,The Artist s Studio,https://www.wga.hu/art/c/corot/corot163.jpg,56 x 46 cm
|
||||
Jean-François Millet,Haystacks Autumn,https://www.wga.hu/art/m/millet/06autumn.jpg,85 x 110 cm
|
||||
Jean-François Millet,The Angelus,https://www.wga.hu/art/m/millet/04angel.jpg,56 x 66 cm
|
||||
Jean-François Millet,The Gleaners,https://www.wga.hu/art/m/millet/03gleaner.jpg,"85,5 x 111 cm"
|
||||
Jean-Honoré Fragonard,A Young Girl Reading,https://www.wga.hu/art/f/fragonar/father/2/04readin.jpg,81 x 65 cm
|
||||
Jean-Honoré Fragonard,The Progress of Love,https://www.wga.hu/art/f/fragonar/father/2/011pursui.jpg,318 x 216 cm
|
||||
Jean-Honoré Fragonard,The Stolen Kiss,https://www.wga.hu/art/f/fragonar/father/2/11stolen.jpg,45 x 55 cm
|
||||
Jean-Honoré Fragonard,The Swing,https://www.wga.hu/art/f/fragonar/father/1/021seesa.jpg,120 x 95 cm
|
||||
John Constable,The Hay Wain,https://www.wga.hu/art/c/constabl/haywain.jpg,130 x 185 cm
|
||||
John Constable,The Lock,https://www.wga.hu/art/c/constabl/lock.jpg,142 x 120 cm
|
||||
Leonardo da Vinci,Allegory with a Wolf and an Eagle,https://www.wga.hu/art/l/leonardo/09study/12allego.jpg,170 x 280 mm
|
||||
Leonardo da Vinci,Anatomical Deawing of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
|
||||
Leonardo da Vinci,Anatomical Drawings of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of a Bear s Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Blood Supply to the Liver,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Bones of the Foot and Study of the Shoulder,https://www.wga.hu/art/l/leonardo/10anatom/3should1.jpg,289 x 199 mm
|
||||
Leonardo da Vinci,Anatomical Studies of the Developing Foetus,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Facial Nerves,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Hand,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Human Skeleton,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Madonna Lactans,https://www.wga.hu/art/l/leonardo/03/3litta.jpg,42 x 33 cm
|
||||
Leonardo da Vinci,Mona Lisa,https://www.wga.hu/art/l/leonardo/04/0monalis.jpg,77 x 53 cm
|
||||
Leonardo da Vinci,The Last Supper,https://www.wga.hu/art/l/leonardo/03/4lastsu1.jpg,460 x 880 cm
|
||||
Masaccio,Baptism of the Neophytes,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/baptism.jpg,255 x 162 cm
|
||||
Masaccio,Madonna and Child Enthroned and Twelve Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Madonna and Child with Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Madonna and Child with Five Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Raising of the Son of Teophilus and St Peter Enthroned,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/theo_pet.jpg,230 x 598 cm
|
||||
Masaccio,Saints Jerome and John the Baptist,https://www.wga.hu/art/m/masaccio/z_panels/jerome.jpg,125 x 59 cm
|
||||
Masaccio,San Giovenale Triptych,https://www.wga.hu/art/m/masaccio/z_panels/giovena1.jpg,110 x 65 cm
|
||||
Masaccio,St Peter Healing the Sick with His Shadow,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/shadow.jpg,230 x 162 cm
|
||||
Masaccio,The Distribution of Alms and Death of Ananias,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/distrib.jpg,230 x 162 cm
|
||||
Masaccio,The agony of Christ in the garden of Gethsemane St Jerome as penitent,https://www.wga.hu/art/m/masaccio/z_panels/garden.jpg,62 x 44 cm
|
||||
Michelangelo,Cumaean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/05_6si3.jpg,375 x 380 cm
|
||||
Michelangelo,Delphic Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/01_7si1.jpg,350 x 380 cm
|
||||
Michelangelo,Doni Tondo,https://www.wga.hu/art/m/michelan/2paintin/1/1donitop.jpg,
|
||||
Michelangelo,Erythraean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
|
||||
Michelangelo,Erythraean Sibyl and arched window with a view,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
|
||||
Michelangelo,Jacob and Joseph,https://www.wga.hu/art/m/michelan/3sistina/6lunette/02/lu02jac.jpg,215 x 430 cm
|
||||
Michelangelo,Judith and Holofernes,https://www.wga.hu/art/m/michelan/3sistina/5spandre/00_1pe1.jpg,570 x 970 cm
|
||||
Michelangelo,Studies of a Horse with Two Nude Riders and a Male Torso,https://www.wga.hu/art/m/michelan/4drawing/02/25cascin.jpg,222 x 198 mm
|
||||
Michelangelo,The Ancestors of Christ Zerubbabel Abiud and Eliakim,https://www.wga.hu/art/m/michelan/3sistina/6lunette/05/lu05zer.jpg,215 x 430 cm
|
||||
Michelangelo,The Creation,https://www.wga.hu/art/m/michelan/3sistina/1genesis/5eve/05_2ce5f.jpg,
|
||||
Michelangelo,The Creation of Adam,https://www.wga.hu/art/m/michelan/3sistina/1genesis/6adam/06_3ce6.jpg,280 x 570 cm
|
||||
Michelangelo,The Deluge,https://www.wga.hu/art/m/michelan/3sistina/1genesis/2flood/02_3ce2.jpg,280 x 570 cm
|
||||
Michelangelo,The Drunkenness of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/1drunken/01_2ce1f.jpg,
|
||||
Michelangelo,The Sacrifice of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/3sacrifi/03_2ce3f.jpg,
|
||||
Michelangelo,Three Standing Men in Wide Cloaks Turned to the Left,https://www.wga.hu/art/m/michelan/4drawing/01/03early.jpg,292 x 200 mm
|
||||
Nicolas Poussin,Rape of the Sabine Women,https://www.wga.hu/art/p/poussin/2a/05sabin1.jpg,"154,6 x 209,9 cm"
|
||||
Odilon Redon,Closed Eyes,https://www.wga.hu/art/r/redon/closedey.jpg,44 x 36 cm
|
||||
Parmigianino,Pallas Athena,https://www.wga.hu/art/p/parmigia/1/pallas_a.jpg,"63,8 x 45,1 cm"
|
||||
Parmigianino,Self-Portrait in a Convex Mirror,https://www.wga.hu/art/p/parmigia/1/convex.jpg,
|
||||
Parmigianino,Virgin and Child with an Angel,https://www.wga.hu/art/p/parmigia/1/virgin_a.jpg,26 x 19 cm
|
||||
Parmigianino,Vision of Saint Jerome,https://www.wga.hu/art/p/parmigia/1/v_jerome.jpg,343 x 149 cm
|
||||
Paul Cézanne,Apples and Oranges,https://www.wga.hu/art/c/cezanne/4/4still6.jpg,74 x 93 cm
|
||||
Paul Cézanne,Houses at l'Estaque,https://www.wga.hu/art/c/cezanne/2/1lands09.jpg,42 x 59 cm
|
||||
Paul Cézanne,Mont Sainte-Victoire,https://www.wga.hu/art/c/cezanne/3/1lands09.jpg,67 x 92 cm
|
||||
Paul Cézanne,The Card Players,https://www.wga.hu/art/c/cezanne/4/2figure1.jpg,48 x 57 cm
|
||||
Paul Gauguin,Nevermore,https://www.wga.hu/art/g/gauguin/06/tahiti63.jpg,60 x 116 cm
|
||||
Paul Gauguin,The Yellow Christ,https://www.wga.hu/art/g/gauguin/02/6pould05.jpg,92 x 73 cm
|
||||
Paul Gauguin,Two Tahitian Women,https://www.wga.hu/art/g/gauguin/06/tahiti72.jpg,94 x 72 cm
|
||||
Paul Gauguin,Vision After the Sermon,https://www.wga.hu/art/g/gauguin/02/3ponta04.jpg,73 x 92 cm
|
||||
Paul Gauguin,Where Do We Come From,https://www.wga.hu/art/g/gauguin/06/tahiti66.jpg,141 x 346 cm
|
||||
Paul Gauguin,Woman with a Hat,https://www.wga.hu/art/g/gauguin/09/graphi16.jpg,150 x 108 mm
|
||||
Paul Gauguin,Young Girls by the Sea,https://www.wga.hu/art/g/gauguin/04/tahiti23.jpg,68 x 92 cm
|
||||
Peter Paul Rubens,Assumption of Mary,https://www.wga.hu/art/r/rubens/7graphic/04sketch.jpg,88 x 59 cm
|
||||
Peter Paul Rubens,Cupid Carving his Bow,https://www.wga.hu/art/r/rubens/21mythol/14mythol.jpg,142 x 108 cm
|
||||
Peter Paul Rubens,Deer and Monkeys on the reverse of Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Esther before Ahasuerus,https://www.wga.hu/art/r/rubens/7graphic/04sketc.jpg,33 x 32 cm
|
||||
Peter Paul Rubens,Feast of Herod,https://www.wga.hu/art/r/rubens/15biblic/10feast.jpg,208 x 264 cm
|
||||
Peter Paul Rubens,Judith and the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Music-making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
|
||||
Peter Paul Rubens,St Francis of Assisi Receiving the Stigmata,https://www.wga.hu/art/r/rubens/14religi/78religi.jpg,264 x 192 cm
|
||||
Peter Paul Rubens,The Education of the Virgin,https://www.wga.hu/art/r/rubens/13religi/58religi.jpg,193 x 140 cm
|
||||
Peter Paul Rubens,The Honeysuckle Bower,https://www.wga.hu/art/r/rubens/41portra/08artist.jpg,"178 x 136,5 cm"
|
||||
Peter Paul Rubens,The Martyrdom of St Dionysius,https://www.wga.hu/art/r/rubens/14religi/73religi.jpg,455 x 347 cm
|
||||
Peter Paul Rubens,The Virgin Flanked by Music Making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
|
||||
Peter Paul Rubens,Virgin and Child Enthroned with Four Saints,https://www.wga.hu/art/r/rubens/7graphic/11sketch.jpg,80 x 56 cm
|
||||
Pierre Puvis de Chavannes,The Poor Fisherman,https://www.wga.hu/art/p/puvis/fisherma.jpg,156 x 193 cm
|
||||
Pierre-Auguste Renoir,Dance at Le Moulin de la Galette,https://www.wga.hu/art/r/renoir/2/2renoi13.jpg,131 x 175 cm
|
||||
Pierre-Auguste Renoir,Diana,https://www.wga.hu/art/r/renoir/1/1renoi06.jpg,196 x 130 cm
|
||||
Pierre-Auguste Renoir,Girl with a Watering Can,https://www.wga.hu/art/r/renoir/2/2renoi19.jpg,100 x 73 cm
|
||||
Pierre-Auguste Renoir,Houses at Chatou,https://www.wga.hu/art/r/renoir/2/2renoi31.jpg,81 x 100 cm
|
||||
Pierre-Auguste Renoir,Luncheon of the Boating Party,https://www.wga.hu/art/r/renoir/3/3renoi11.jpg,130 x 173 cm
|
||||
Pierre-Auguste Renoir,Maternity,https://www.wga.hu/art/r/renoir/3/3renoi26.jpg,82 x 65 cm
|
||||
Pierre-Auguste Renoir,The Farmers Lunch,https://www.wga.hu/art/r/renoir/2/2renoi12.jpg,54 x 65 cm
|
||||
Pierre-Auguste Renoir,Two Sisters On the Terrace,https://www.wga.hu/art/r/renoir/3/3renoi06.jpg,100 x 80 cm
|
||||
Pontormo,Joseph in Egypt,https://www.wga.hu/art/p/pontormo/1/03josep3.jpg,96 x 109 cm
|
||||
Pontormo,Supper at Emmaus,https://www.wga.hu/art/p/pontormo/3/04emmaus.jpg,230 x 173 cm
|
||||
Raphael,Angel with a Tambourine,https://www.wga.hu/art/r/raphael/7drawing/1/34study.jpg,189 x 126 mm
|
||||
Raphael,Bache Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
|
||||
Raphael,Baldassare Castiglione,https://www.wga.hu/art/r/raphael/5roma/3/01castig.jpg,82 x 67 cm
|
||||
Raphael,Bindo Altoviti,https://www.wga.hu/art/r/raphael/5roma/2/01altovi.jpg,60 x 44 cm
|
||||
Raphael,Cherubs of the Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,Coronation of Saint Nicholas from Tolentino,https://www.wga.hu/art/r/raphael/7drawing/1/01study.jpg,409 x 265 mm
|
||||
Raphael,Half-length portrait of a young woman in profile,https://www.wga.hu/art/r/raphael/7drawing/3/09drawim.jpg,254 x 160 mm
|
||||
Raphael,Interior of the Pantheon,https://www.wga.hu/art/r/raphael/7drawing/3/08drawin.jpg,277 x 407 mm
|
||||
Raphael,Justice,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/5/1tondo4.jpg,
|
||||
Raphael,La Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
|
||||
Raphael,Lucca Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
|
||||
Raphael,Madonna and Child Madonna della Seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Madonna del Granduca,https://www.wga.hu/art/r/raphael/2firenze/1/22grand.jpg,84 x 56 cm
|
||||
Raphael,Madonna della Loggia,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
|
||||
Raphael,Madonna della seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Prophet Isaiah,https://www.wga.hu/art/r/raphael/5roma/1/08isaiah.jpg,250 x 155 cm
|
||||
Raphael,Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,St Michael and the Dragon by Cimabue,https://www.wga.hu/art/r/raphael/2firenze/1/25drago2.jpg,31 x 27 cm
|
||||
Raphael,Study of a Kneeling Nude Girl for The Entombment,https://www.wga.hu/art/r/raphael/7drawing/1/11study.jpg,230 x 319 mm
|
||||
Raphael,The School of Athens,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/1/athens.jpg,
|
||||
Raphael,The Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,The Virgin and Child with the Young St John Tondo,https://www.wga.hu/art/r/raphael/7drawing/3/35drawin.jpg,193 x 228 mm
|
||||
Raphael,Vision of the Trones,https://www.wga.hu/art/r/raphael/4stanze/4constan/3vision.jpg,
|
||||
Rembrandt,Christ and the Woman Taken in Adultery,https://www.wga.hu/art/r/rembrand/13biblic/32newtes.jpg,84 x 65 cm
|
||||
Rembrandt,Lucretia,https://www.wga.hu/art/r/rembrand/17histor/09histor.jpg,"105 x 92,5 cm"
|
||||
Rembrandt,The Anatomy Lesson of Dr Nicolaes Tulp,https://www.wga.hu/art/r/rembrand/26group/01group.jpg,170 x 217 cm
|
||||
Rembrandt,The Night Watch,https://www.wga.hu/art/r/rembrand/26group/05group.jpg,363 x 437 cm
|
||||
Rembrandt,The Red Trees,https://www.wga.hu/art/r/rembrand/51etchin/4/212.jpg,213 x 279 mm
|
||||
Sandro Botticelli,Birth of Christ,https://www.wga.hu/art/b/botticel/21/4birth.jpg,200 x 300 cm
|
||||
Sandro Botticelli,Fortitude,https://www.wga.hu/art/b/botticel/1early/09fortit.jpg,167 x 87 cm
|
||||
Sandro Botticelli,Madonna and Child with an Angel,https://www.wga.hu/art/b/botticel/1early/01madonn.jpg,87 x 60 cm
|
||||
Sandro Botticelli,Madonna in Glory with Seraphim,https://www.wga.hu/art/b/botticel/1early/06glory.jpg,120 x 65 cm
|
||||
Sandro Botticelli,Madonna of the Rose,https://www.wga.hu/art/b/botticel/21/51madonn.jpg,40 x 28 cm
|
||||
Sandro Botticelli,Madonna of the Rose Garden,https://www.wga.hu/art/b/botticel/1early/07rosega.jpg,124 x 65 cm
|
||||
Sandro Botticelli,Portrait of Dante,https://www.wga.hu/art/b/botticel/7portrai/14dante.jpg,"54,7 x 47,5 cm"
|
||||
Sandro Botticelli,Portrait of a Man with a Medal of Cosimo the Elder,https://www.wga.hu/art/b/botticel/7portrai/04medal.jpg,"57,5 x 44 cm"
|
||||
Sandro Botticelli,Portrait of a Young Man possibly Giuliano de Medici,https://www.wga.hu/art/b/botticel/7portrai/06medici.jpg,76 x 53 cm
|
||||
Sandro Botticelli,Predella of the Annunciation,https://www.wga.hu/art/b/botticel/22/70cestel.jpg,150 x 156 cm
|
||||
Sandro Botticelli,Purgatory,https://www.wga.hu/art/b/botticel/93dante/20purgat.jpg,320 x 470 mm
|
||||
Sandro Botticelli,St John on Patmos,https://www.wga.hu/art/b/botticel/8smarco/21predel.jpg,21 x 269 cm
|
||||
Sandro Botticelli,The Birth of Venus,https://www.wga.hu/art/b/botticel/5allegor/29birth.jpg,173 x 279 cm
|
||||
Sandro Botticelli,The Discovery of the Body of Holofernes,https://www.wga.hu/art/b/botticel/1early/13holofe.jpg,31 x 25 cm
|
||||
Sandro Botticelli,The Return of Judith to Bethulia,https://www.wga.hu/art/b/botticel/1early/12judith.jpg,31 x 24 cm
|
||||
Sandro Botticelli,Three Scenes from the Story of Esther,https://www.wga.hu/art/b/botticel/1early/14esther.jpg,48 x 132 cm
|
||||
Simone Martini,Annunciation,https://www.wga.hu/art/s/simone/6annunci/a_angel.jpg,31 x 22cm
|
||||
Simone Martini,Annunciation to Mary,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
|
||||
Simone Martini,Annunciation to Saint Anne,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
|
||||
Simone Martini,Blessing Christ,https://www.wga.hu/art/s/simone/4altars/1louis/5naples.jpg,76 x 46 cm
|
||||
Simone Martini,Saint Andrew,https://www.wga.hu/art/s/simone/4altars/6other/4andrew.jpg,57 x 38 cm
|
||||
Simone Martini,Saint Ansanus,https://www.wga.hu/art/s/simone/4altars/6other/1ansanus.jpg,58 x 38 cm
|
||||
Théodore Géricault,The Raft of the Medusa,https://www.wga.hu/art/g/gericaul/1/105geric.jpg,491 x 716 cm
|
||||
Titian,Christ the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
|
||||
Titian,Orpheus and Eurydice,https://www.wga.hu/art/t/tiziano/08/03orpheu.jpg,39 x 53 cm
|
||||
Titian,Portrait of Isabella of Portugal,https://www.wga.hu/art/t/tiziano/10/1/13isabel.jpg,117 x 93 cm
|
||||
Titian,The Birth of Adonis,https://www.wga.hu/art/t/tiziano/08/01birth.jpg,35 x 162 cm
|
||||
Titian,The Christ Child as the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
|
||||
Titian,The Legend of Polydoros,https://www.wga.hu/art/t/tiziano/08/02polydo.jpg,35 x 162 cm
|
||||
Titian,The Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
|
||||
Titian,Venus Anadyomene,https://www.wga.hu/art/t/tiziano/08/07anadyo.jpg,76 x 57 cm
|
||||
Titian,Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
|
||||
Vincent van Gogh,Bal du moulin de la Galette,https://www.wga.hu/art/g/gogh_van/06/paris05.jpg,46 x 38 cm
|
||||
Vincent van Gogh,Haystacks,https://www.wga.hu/art/g/gogh_van/18/2arles08.jpg,241 x 319 mm
|
||||
Vincent van Gogh,Saint Paul,https://www.wga.hu/art/g/gogh_van/11/1asylu01.jpg,95 x 76 cm
|
||||
Vincent van Gogh,Self-Portrait with Bandaged Ear,https://www.wga.hu/art/g/gogh_van/16/selfpo31.jpg,60 x 49 cm
|
||||
Vincent van Gogh,Sunflowers,https://www.wga.hu/art/g/gogh_van/09/arles63.jpg,92 x 71 cm
|
||||
Vincent van Gogh,Two Children Are Threatened by a Nightingale,https://www.wga.hu/art/g/gogh_van/15/auvers25.jpg,52 x 46 cm
|
||||
Vincent van Gogh,Wheatfield with Crows,https://www.wga.hu/art/g/gogh_van/15/auvers33.jpg,"50,5 x 103 cm"
|
||||
Édouard Manet,A Bar at the Folies-Berg re,https://www.wga.hu/art/m/manet/5/5late09.jpg,96 x 130 cm
|
||||
Édouard Manet,Luncheon on the Grass,https://www.wga.hu/art/m/manet/1/5dejeun1.jpg,208 x 265 cm
|
||||
Édouard Manet,Olympia,https://www.wga.hu/art/m/manet/1/4olympi1.jpg,131 x 190 cm
|
||||
Édouard Manet,The Balcony,https://www.wga.hu/art/m/manet/2/2manet18.jpg,170 x 125 cm
|
||||
|
@@ -1,12 +1,14 @@
|
||||
# Art Gallery
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline 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, 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.
|
||||
Interactive virtual art gallery: zoomable historical timeline 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/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 +21,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,43 +32,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:artist-palette # optional: JSONB column for PainterPalette enrichment
|
||||
npm run import-painter-palette # optional: metadata + influence links from Inputs/PainterPalette.csv
|
||||
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings)
|
||||
npm run migrate:artist-checkup-flags # same flags for artist portraits (bio debug)
|
||||
npm run migrate:painting-annotations # art-history notes on painting detail
|
||||
npm run update-painting-annotations # load curated notes (+ optional --wikipedia)
|
||||
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
|
||||
npm run start:prod
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
**Production URLs:**
|
||||
- Public: http://gallery.mysuperlab.netcraze.pro (via reverse proxy)
|
||||
- LAN: http://192.168.10.70:3520
|
||||
Open https://devgallery.mysuperlab.netcraze.pro (or http://localhost:5173 locally).
|
||||
|
||||
See [Documentation/setup.md](Documentation/setup.md#production-deployment) for nginx/systemd configs in `deploy/`.
|
||||
**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 # API — PORT from .env (3520 or 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)
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,39 +1,140 @@
|
||||
import type {
|
||||
TimelineData,
|
||||
CatalogBootstrap,
|
||||
YearBounds,
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
MovementGalleryDetail,
|
||||
Painting,
|
||||
PaintingDetail,
|
||||
ArtistNavigation,
|
||||
CatalogSearchResponse,
|
||||
TourSummary,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
|
||||
|
||||
const API = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
let apiLocale: AppLocale = readStoredLocale();
|
||||
|
||||
export function setApiLocale(locale: AppLocale) {
|
||||
apiLocale = locale;
|
||||
}
|
||||
|
||||
export function getApiLocale(): AppLocale {
|
||||
return apiLocale;
|
||||
}
|
||||
|
||||
function localeParams(base?: URLSearchParams): URLSearchParams {
|
||||
const params = base ?? new URLSearchParams();
|
||||
if (apiLocale !== 'en') params.set('locale', apiLocale);
|
||||
return params;
|
||||
}
|
||||
|
||||
function localizedPath(path: string, params?: URLSearchParams): string {
|
||||
const qs = localeParams(params).toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
const fetchCredentials: RequestInit = { credentials: 'include' };
|
||||
|
||||
export type AuthRole = 'user' | 'curator';
|
||||
|
||||
export interface AuthState {
|
||||
role: AuthRole;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { ...fetchCredentials, ...init });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function imageUrl(path: string | null | undefined): string {
|
||||
if (!path) return '/placeholder-art.svg';
|
||||
return `/images/${path}`;
|
||||
export async function getAuthMe(): Promise<AuthState> {
|
||||
return fetchJson<AuthState>(`${API}/auth/me`);
|
||||
}
|
||||
|
||||
export function portraitUrl(path: string | null | undefined, revision?: number): string {
|
||||
const base = imageUrl(path);
|
||||
export async function loginCurator(username: string, password: string): Promise<AuthState> {
|
||||
const res = await fetch(`${API}/auth/login`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Login failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logoutCurator(): Promise<void> {
|
||||
const res = await fetch(`${API}/auth/logout`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export function imageUrl(path: string | null | undefined, revision?: number | null): string {
|
||||
const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
|
||||
if (!revision || base.startsWith('/placeholder')) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
}
|
||||
|
||||
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
|
||||
export function galleryImageUrl(painting: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
export function portraitUrl(
|
||||
path: string | null | undefined,
|
||||
revision?: number | null,
|
||||
options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null }
|
||||
): string {
|
||||
const cacheRevision =
|
||||
revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined;
|
||||
return imageUrl(path, cacheRevision);
|
||||
}
|
||||
|
||||
export function portraitThumbUrl(
|
||||
artist: {
|
||||
portrait_thumb_path?: string | null;
|
||||
portrait_path?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
},
|
||||
revision?: number | null
|
||||
): string {
|
||||
const path = artist.portrait_thumb_path || artist.portrait_path;
|
||||
const cacheRevision =
|
||||
revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined;
|
||||
return portraitUrl(path, cacheRevision);
|
||||
}
|
||||
|
||||
export function paintingImageRevision(
|
||||
painting: {
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): number | undefined {
|
||||
const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key;
|
||||
if (apiRevision != null) return apiRevision;
|
||||
if (sessionRevision != null) return sessionRevision;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
|
||||
export function galleryImageUrl(
|
||||
painting: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const revision = paintingImageRevision(painting, sessionRevision);
|
||||
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
|
||||
if (painting.image_path) return imageUrl(painting.image_path, revision);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,27 +143,35 @@ export function galleryImageUrlWithRevision(
|
||||
id?: number;
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
revision?: number
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const base = galleryImageUrl(painting);
|
||||
if (!base || !revision) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
return galleryImageUrl(painting, sessionRevision);
|
||||
}
|
||||
|
||||
export function paintingImageUrl(painting: {
|
||||
id: number;
|
||||
image_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
checkup_fixed?: boolean;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
export function paintingImageUrl(
|
||||
painting: {
|
||||
id: number;
|
||||
image_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
checkup_fixed?: boolean;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const revision = paintingImageRevision(painting, sessionRevision);
|
||||
if (painting.image_path) return imageUrl(painting.image_path, revision);
|
||||
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
|
||||
if (painting.checkup_fixed) return null;
|
||||
return `/api/paintings/${painting.id}/image?size=full`;
|
||||
}
|
||||
|
||||
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
|
||||
validateDebugUploadFile(file);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -74,7 +183,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
const comma = result.indexOf(',');
|
||||
resolve({
|
||||
imageData: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
@@ -82,8 +191,59 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
});
|
||||
}
|
||||
|
||||
async function fileToBase64Raw(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('Could not read file'));
|
||||
return;
|
||||
}
|
||||
const comma = result.indexOf(',');
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mimeTypeFromFilename(filename: string): string | null {
|
||||
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'avif':
|
||||
return 'image/avif';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDebugUploadFile(file: File): void {
|
||||
const maxBytes = 15 * 1024 * 1024;
|
||||
if (file.size <= 0) {
|
||||
throw new Error('Selected file is empty.');
|
||||
}
|
||||
if (file.size > maxBytes) {
|
||||
throw new Error('Image too large (max 15 MB).');
|
||||
}
|
||||
const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name);
|
||||
if (!file.type.startsWith('image/') && !nameOk) {
|
||||
throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).');
|
||||
}
|
||||
}
|
||||
|
||||
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -96,7 +256,10 @@ async function postJsonImageAction<T>(url: string, payload: { imageData: string;
|
||||
}
|
||||
|
||||
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
|
||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error('Preload failed');
|
||||
return res.json();
|
||||
}
|
||||
@@ -104,12 +267,17 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
||||
export interface FixPaintingImageResult {
|
||||
imagePath: string | null;
|
||||
thumbnailPath: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface FixArtistPortraitResult {
|
||||
portraitPath: string | null;
|
||||
portraitThumbPath?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
@@ -163,25 +331,44 @@ export interface PaintingCheckupData {
|
||||
export const api = {
|
||||
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
||||
|
||||
getTimeline: (start: number, end: number) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
getCatalogBootstrap: (start?: number, end?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
|
||||
},
|
||||
|
||||
getTimeline: (start: number, end: number) => {
|
||||
const params = new URLSearchParams({ start: String(start), end: String(end) });
|
||||
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
|
||||
},
|
||||
|
||||
search: (q: string, options?: { limit?: number; types?: string }) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||
if (options?.types) params.set('types', options.types);
|
||||
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
|
||||
},
|
||||
|
||||
getArtists: (start?: number, end?: number, movementId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
if (movementId != null) params.set('movement_id', String(movementId));
|
||||
return fetchJson<Artist[]>(`${API}/artists?${params}`);
|
||||
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
|
||||
},
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
/** Lightweight artist rows for the timeline (no biography text). */
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||
|
||||
getArtistNavigation: (id: number) =>
|
||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
||||
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
|
||||
|
||||
getPaintingDebugImageSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||
@@ -195,6 +382,7 @@ export const api = {
|
||||
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||
) =>
|
||||
fetch(`${API}/paintings/${id}/fix-image`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl, ...context }),
|
||||
@@ -207,7 +395,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
clearPaintingImage: (id: number) =>
|
||||
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
|
||||
fetch(`${API}/paintings/${id}/clear-image`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||
@@ -216,7 +404,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
deletePainting: (id: number) =>
|
||||
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||
fetch(`${API}/paintings/${id}`, { ...fetchCredentials, method: 'DELETE' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Remove failed: ${res.status}`);
|
||||
@@ -236,6 +424,7 @@ export const api = {
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
@@ -259,6 +448,7 @@ export const api = {
|
||||
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||
) =>
|
||||
fetch(`${API}/artists/${id}/fix-portrait`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl, ...context }),
|
||||
@@ -271,7 +461,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
clearArtistPortrait: (id: number) =>
|
||||
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
|
||||
fetch(`${API}/artists/${id}/clear-portrait`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||
@@ -289,6 +479,7 @@ export const api = {
|
||||
flags: { checked?: boolean; fixed?: boolean }
|
||||
) =>
|
||||
fetch(`${API}/artists/${id}/checkup-flags`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flags),
|
||||
@@ -299,8 +490,411 @@ export const api = {
|
||||
}
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
getTranslationCoverage: (locale = 'ru') =>
|
||||
fetchJson<{ locale: string; coverage: Record<string, number> }>(
|
||||
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getTranslationWorklist: (entityType: string, locale = 'ru') =>
|
||||
fetchJson<{ items: TranslationWorklistItem[] }>(
|
||||
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getEntityTranslation: (entityType: string, id: number) =>
|
||||
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
|
||||
|
||||
saveEntityTranslation: (
|
||||
entityType: string,
|
||||
id: number,
|
||||
payload: { locale: string; fields: Record<string, string>; status?: string }
|
||||
) =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Save failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Publish failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
listInfluences: (params: {
|
||||
artistId?: number;
|
||||
paintingId?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
if (params.q) qs.set('q', params.q);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.offset) qs.set('offset', String(params.offset));
|
||||
const q = qs.toString();
|
||||
return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>(
|
||||
`${API}/influences${q ? `?${q}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
return fetchJson<InfluenceGraph>(`${API}/influences/graph?${qs.toString()}`);
|
||||
},
|
||||
|
||||
createInfluence: (payload: {
|
||||
paintingId: number;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId?: number;
|
||||
sourceArtistId?: number;
|
||||
sourceMovementId?: number;
|
||||
notes?: string;
|
||||
source?: string;
|
||||
sourceUrl?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ id: number }>;
|
||||
}),
|
||||
|
||||
updateInfluence: (
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
notes: string;
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
confidence: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number;
|
||||
sourceArtistId: number;
|
||||
sourceMovementId: number;
|
||||
}>,
|
||||
) =>
|
||||
fetch(`${API}/influences/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ id: number }>;
|
||||
}),
|
||||
|
||||
deleteInfluence: (id: number) =>
|
||||
fetch(`${API}/influences/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'DELETE',
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean }>;
|
||||
}),
|
||||
|
||||
parseInfluenceImport: async (file: File, sheet?: string) => {
|
||||
const contentBase64 = await fileToBase64Raw(file);
|
||||
return fetch(`${API}/influences/import/parse`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
sheet,
|
||||
contentBase64,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Parse failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportParseResult>;
|
||||
});
|
||||
},
|
||||
|
||||
previewInfluenceImport: (payload: {
|
||||
rows: Record<string, string>[];
|
||||
mapping: Record<string, string>;
|
||||
sourceLabel?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/preview`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Preview failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportPreview>;
|
||||
}),
|
||||
|
||||
commitInfluenceImport: (payload: {
|
||||
proposals: InfluenceImportProposal[];
|
||||
fileName?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/commit`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & {
|
||||
code?: string;
|
||||
priorImport?: InfluencePriorImport;
|
||||
};
|
||||
err.code = body.code;
|
||||
err.priorImport = body.priorImport;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
|
||||
}),
|
||||
|
||||
listPublishedTours: () =>
|
||||
fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
|
||||
|
||||
listAdminTours: () =>
|
||||
fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
|
||||
|
||||
getTour: (id: number) =>
|
||||
fetchJson<TourGalleryDetail & { locale?: string }>(`${API}/tours/${id}`),
|
||||
|
||||
createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) =>
|
||||
fetch(`${API}/tours`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ tour: TourSummary }>;
|
||||
}),
|
||||
|
||||
updateTour: (
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'draft' | 'published';
|
||||
coverPaintingId: number | null;
|
||||
}>,
|
||||
) =>
|
||||
fetch(`${API}/tours/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ tour: TourSummary }>;
|
||||
}),
|
||||
|
||||
deleteTour: (id: number) =>
|
||||
fetch(`${API}/tours/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'DELETE',
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean }>;
|
||||
}),
|
||||
|
||||
saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) =>
|
||||
fetch(`${API}/tours/${id}/stops`, {
|
||||
...fetchCredentials,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stops }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Save stops failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{
|
||||
ok: boolean;
|
||||
stopCount: number;
|
||||
paintings: Painting[];
|
||||
stopBodies: Record<number, string>;
|
||||
}>;
|
||||
}),
|
||||
|
||||
preloadArtistImages,
|
||||
};
|
||||
|
||||
export interface TranslationWorklistItem {
|
||||
entityId: number;
|
||||
label: string;
|
||||
publishedCount: number;
|
||||
draftCount: number;
|
||||
missingFields: string[];
|
||||
}
|
||||
|
||||
export interface TranslationDetail {
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
canonical: Record<string, unknown>;
|
||||
translatableFields: string[];
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
field_name: string;
|
||||
value: string;
|
||||
status: string;
|
||||
source: string | null;
|
||||
updated_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InfluenceEdgeItem {
|
||||
id: number;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
paintingYear: number | null;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string | null;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
aspects: string | null;
|
||||
quote: string | null;
|
||||
confidence: string | null;
|
||||
discoveredVia: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface InfluenceGraph {
|
||||
focus: { artistId: number; paintingId: number | null; label: string };
|
||||
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
||||
edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface InfluencePriorImport {
|
||||
importedAt: string;
|
||||
username: string | null;
|
||||
fileName: string | null;
|
||||
inserted: number | null;
|
||||
contentHash: string | null;
|
||||
payloadHash: string | null;
|
||||
match: 'file' | 'data' | 'unknown';
|
||||
}
|
||||
|
||||
export type { TourSummary, TourGalleryDetail };
|
||||
|
||||
export interface InfluenceImportParseResult {
|
||||
filename: string;
|
||||
format: string;
|
||||
sheets: string[] | null;
|
||||
sheet: string | null;
|
||||
columns: string[];
|
||||
rowCount: number;
|
||||
sampleRows: Record<string, string>[];
|
||||
rows?: Record<string, string>[];
|
||||
suggestedPreset: string;
|
||||
suggestedMapping: Record<string, string>;
|
||||
roles: string[];
|
||||
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportProposal {
|
||||
rowIndex: number;
|
||||
direction: string;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: string;
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string;
|
||||
token: string;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
confidence: string;
|
||||
discoveredVia: string;
|
||||
edgeKey: string;
|
||||
action: 'create' | 'skip';
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportPreview {
|
||||
proposals: InfluenceImportProposal[];
|
||||
warnings: Array<{
|
||||
rowIndex: number;
|
||||
message: string;
|
||||
token?: string;
|
||||
direction?: string;
|
||||
candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>;
|
||||
}>;
|
||||
counts: {
|
||||
rows: number;
|
||||
proposals: number;
|
||||
willCreate: number;
|
||||
willSkip: number;
|
||||
errors: number;
|
||||
};
|
||||
contentHash?: string | null;
|
||||
payloadHash?: string | null;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
imageUrl: string,
|
||||
context?: { searchUrl?: string; source?: string }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.artist-bio {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e8d5b5;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Artist } from '../types';
|
||||
import {
|
||||
api,
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type FixArtistPortraitResult,
|
||||
} from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import '../components/PaintingDetail.css';
|
||||
import '../components/GalleryLoadingMarker.css';
|
||||
import './ArtistBio.css';
|
||||
|
||||
interface Props {
|
||||
@@ -51,11 +54,13 @@ export default function ArtistBio({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
|
||||
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
|
||||
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
|
||||
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
|
||||
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
|
||||
const portraitSrc = uploading
|
||||
? null
|
||||
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
|
||||
|
||||
const lifespan =
|
||||
artist.birth_year && artist.death_year
|
||||
@@ -71,6 +76,9 @@ export default function ArtistBio({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -91,7 +99,7 @@ export default function ArtistBio({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, artist.id, artist.name]);
|
||||
}, [debugMode, uploading, artist.id, artist.name]);
|
||||
|
||||
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
|
||||
if (onArtistPortraitFixed) {
|
||||
@@ -197,20 +205,27 @@ export default function ArtistBio({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadArtistPortrait(artist.id, file);
|
||||
await applyPortraitUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -219,6 +234,13 @@ export default function ArtistBio({
|
||||
|
||||
return (
|
||||
<div className="artist-bio">
|
||||
{uploading && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="debug-upload-page-overlay"
|
||||
message={uploadStatus ?? 'Loading…'}
|
||||
/>
|
||||
)}
|
||||
<header className="bio-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back</button>
|
||||
<h1>{artist.name}</h1>
|
||||
@@ -227,9 +249,9 @@ export default function ArtistBio({
|
||||
|
||||
<div className="bio-content">
|
||||
<div
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
|
||||
>
|
||||
{showPortrait ? (
|
||||
{showPortrait && portraitSrc ? (
|
||||
<img
|
||||
src={portraitSrc}
|
||||
alt={artist.name}
|
||||
@@ -273,14 +295,17 @@ export default function ArtistBio({
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" aria-label="Debug portrait search">
|
||||
<aside
|
||||
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||||
aria-label="Debug portrait search"
|
||||
>
|
||||
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
|
||||
<p className="debug-image-query">
|
||||
{debugSearch?.query ?? `${artist.name} portrait`}
|
||||
</p>
|
||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||
{!debugLoading && debugSearch?.imageUrl && (
|
||||
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||||
{!uploading && !debugLoading && debugSearch?.imageUrl && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
@@ -293,7 +318,7 @@ export default function ArtistBio({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
@@ -301,7 +326,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
@@ -309,7 +334,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixPortrait}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -317,7 +342,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -331,27 +356,18 @@ export default function ArtistBio({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose portrait"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
|
||||
@@ -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,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,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>
|
||||
);
|
||||
}
|
||||
@@ -87,17 +87,39 @@
|
||||
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.32;
|
||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||
}
|
||||
|
||||
.movement-stream-fast {
|
||||
stroke-opacity: 0.42;
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
stroke-width: var(--stream-stroke);
|
||||
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;
|
||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.movement-stream-core {
|
||||
@@ -107,6 +129,33 @@
|
||||
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.55;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.movement-stream-fill.movement-stream-highlighted {
|
||||
filter: brightness(1.65) saturate(1.3);
|
||||
}
|
||||
|
||||
.movement-stream-core.movement-stream-highlighted {
|
||||
opacity: 0.72;
|
||||
filter: brightness(1.5);
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
|
||||
.movement-branch.movement-branch-highlighted {
|
||||
filter: brightness(1.55) saturate(1.2);
|
||||
}
|
||||
|
||||
@keyframes stream-shimmer {
|
||||
@@ -181,16 +230,23 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
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;
|
||||
}
|
||||
|
||||
.movement-flow-label-above {
|
||||
transform: translate(-4px, -100%);
|
||||
}
|
||||
|
||||
.movement-flow-label-below {
|
||||
transform: translate(-4px, 0);
|
||||
}
|
||||
|
||||
.movement-flow-label-btn {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
technique: 'Technique',
|
||||
composition: 'Composition',
|
||||
symbolism: 'Symbolism',
|
||||
history: 'History',
|
||||
subject: 'Subject',
|
||||
const CATEGORY_KEYS: Record<string, string> = {
|
||||
technique: 'categoryTechnique',
|
||||
composition: 'categoryComposition',
|
||||
symbolism: 'categorySymbolism',
|
||||
history: 'categoryHistorical',
|
||||
subject: 'categorySubject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const { t } = useTranslation('annotations');
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">Art history notes</h3>
|
||||
<h3 className="painting-annotations-title">{t('title')}</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const category = CATEGORY_LABELS[ann.category] || ann.category;
|
||||
const categoryKey = CATEGORY_KEYS[ann.category];
|
||||
const category = categoryKey ? t(categoryKey) : ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read source
|
||||
{t('readSource')}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -407,6 +407,40 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.painting-description {
|
||||
max-width: 700px;
|
||||
margin-top: 20px;
|
||||
@@ -552,6 +586,48 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-upload-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-upload-btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-upload-btn-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.debug-upload-page-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.painting-frame-uploading,
|
||||
.bio-portrait-uploading {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.debug-image-panel.debug-image-panel-blocked {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-clear-btn {
|
||||
@@ -576,8 +652,7 @@
|
||||
border-color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-clear-btn:disabled,
|
||||
.debug-upload-btn:disabled {
|
||||
.debug-clear-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
|
||||
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
|
||||
import PaintingLightbox from './PaintingLightbox';
|
||||
import './PaintingDetail.css';
|
||||
import './GalleryLoadingMarker.css';
|
||||
|
||||
interface Props {
|
||||
data: PaintingDetail;
|
||||
artistPaintings?: Painting[];
|
||||
backLabel?: string;
|
||||
tourTitle?: string | null;
|
||||
tourText?: string | null;
|
||||
onBack: () => void;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
onCatalogNavigate: (paintingId: number) => void;
|
||||
@@ -130,6 +137,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';
|
||||
}}
|
||||
@@ -164,6 +173,8 @@ function InfluenceCard({
|
||||
<img
|
||||
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';
|
||||
}}
|
||||
@@ -184,6 +195,9 @@ function InfluenceCard({
|
||||
export default function PaintingDetailView({
|
||||
data,
|
||||
artistPaintings = [],
|
||||
backLabel = '← Back to Gallery',
|
||||
tourTitle = null,
|
||||
tourText = null,
|
||||
onBack,
|
||||
onPaintingClick,
|
||||
onCatalogNavigate,
|
||||
@@ -195,7 +209,9 @@ export default function PaintingDetailView({
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
onPaintingRemoved,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('painting');
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
const inTour = tourText != null;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [imageVersion, setImageVersion] = useState(0);
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
@@ -210,15 +226,13 @@ export default function PaintingDetailView({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseImageUrl = paintingImageUrl(painting);
|
||||
const imageSrc = baseImageUrl
|
||||
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
|
||||
: null;
|
||||
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
|
||||
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
|
||||
const displayImageSrc = uploading ? null : imageSrc;
|
||||
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
|
||||
|
||||
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||
@@ -239,6 +253,7 @@ export default function PaintingDetailView({
|
||||
setFixing(false);
|
||||
setClearing(false);
|
||||
setUploading(false);
|
||||
setUploadStatus(null);
|
||||
setMarkingChecked(false);
|
||||
setApplyingUrl(null);
|
||||
setMoreLoading(false);
|
||||
@@ -251,6 +266,9 @@ export default function PaintingDetailView({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -271,7 +289,7 @@ 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 applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
|
||||
setImageVersion((v) => v + 1);
|
||||
@@ -378,20 +396,27 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadPaintingImage(painting.id, file);
|
||||
await applyImageUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -433,8 +458,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">
|
||||
@@ -443,21 +475,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) => (
|
||||
@@ -490,12 +524,12 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
|
||||
role={imageSrc ? 'button' : undefined}
|
||||
tabIndex={imageSrc ? 0 : undefined}
|
||||
onClick={imageSrc ? () => setFullscreen(true) : undefined}
|
||||
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
|
||||
role={displayImageSrc ? 'button' : undefined}
|
||||
tabIndex={displayImageSrc ? 0 : undefined}
|
||||
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
|
||||
onKeyDown={
|
||||
imageSrc
|
||||
displayImageSrc
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -504,14 +538,14 @@ export default function PaintingDetailView({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={imageSrc ? 'View full screen' : undefined}
|
||||
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
title={displayImageSrc ? 'View full screen' : undefined}
|
||||
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
<div className="painting-frame-image-wrap">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
{displayImageSrc ? (
|
||||
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
{imageSrc && annotations.length > 0 && (
|
||||
{displayImageSrc && annotations.length > 0 && (
|
||||
<PaintingAnnotationMarkers
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
@@ -542,6 +576,16 @@ 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>
|
||||
)}
|
||||
{painting.description && (
|
||||
<div className="painting-description">
|
||||
<p>{painting.description}</p>
|
||||
@@ -550,9 +594,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) => (
|
||||
@@ -569,14 +613,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, {
|
||||
@@ -589,7 +636,7 @@ 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">
|
||||
@@ -597,7 +644,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
@@ -605,7 +652,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixImage}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -613,7 +660,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -627,20 +674,11 @@ export default function PaintingDetailView({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-danger">
|
||||
@@ -657,7 +695,7 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose painting image"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
@@ -667,9 +705,9 @@ export default function PaintingDetailView({
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
|
||||
{fullscreen && imageSrc && (
|
||||
{fullscreen && displayImageSrc && (
|
||||
<PaintingLightbox
|
||||
src={imageSrc}
|
||||
src={displayImageSrc}
|
||||
alt={painting.title}
|
||||
title={painting.title}
|
||||
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './Timeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
@@ -36,13 +40,39 @@ function formatYear(year: number): string {
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react';
|
||||
import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Text, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
@@ -9,8 +10,9 @@ import type {
|
||||
ArtistPeriod,
|
||||
ArtistNavigation,
|
||||
MovementArtistGroup,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
@@ -24,6 +26,36 @@ import {
|
||||
type MovementHallLayout,
|
||||
} from '../utils/movementHallLayout';
|
||||
import './VirtualGallery.css';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
|
||||
const GalleryTextureLoadContext = createContext<{
|
||||
begin: () => void;
|
||||
end: () => void;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
|
||||
* from unmounting the whole 3D scene and leaving a dark window.
|
||||
*/
|
||||
class SceneErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback?: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback ?? null;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface BaseGalleryProps {
|
||||
imageRevisions?: Record<number, number>;
|
||||
@@ -44,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps {
|
||||
data: MovementGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps;
|
||||
interface TourGalleryProps extends BaseGalleryProps {
|
||||
mode: 'tour';
|
||||
data: TourGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
@@ -66,9 +103,6 @@ const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 9;
|
||||
const ROW_GAP = 0.2;
|
||||
const WALL_PADDING = 1.4;
|
||||
/** Above this count, use a long corridor (short back wall, extended side walls). */
|
||||
const CORRIDOR_CATALOG_THRESHOLD = 15;
|
||||
const BACK_WALL_MAX_PAINTINGS = 8;
|
||||
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||
const MAX_WALL_ROWS = 1;
|
||||
const DOOR_WIDTH = 2.4;
|
||||
@@ -259,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
|
||||
return Math.max(MIN_HALL_SIZE, best);
|
||||
}
|
||||
|
||||
/** Left → right on each wall: later works on the left, earlier works on the right. */
|
||||
function orderPaintingsForWallDisplay(paintings: Painting[]) {
|
||||
return [...paintings].sort(comparePaintingsChronological).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit order along the side walls: first half on the left (starting at the
|
||||
* entrance, left of the opening view), second half on the right (ending at the
|
||||
* entrance). Back wall stays empty so first/last always sit on left/right.
|
||||
*/
|
||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
const back: Painting[] = [];
|
||||
const left: Painting[] = [];
|
||||
const right: Painting[] = [];
|
||||
|
||||
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
|
||||
sorted.forEach((p, i) => {
|
||||
if (i % 3 === 0) back.push(p);
|
||||
else if (i % 3 === 1) left.push(p);
|
||||
else right.push(p);
|
||||
});
|
||||
} else {
|
||||
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
|
||||
back.push(...sorted.slice(0, backCount));
|
||||
sorted.slice(backCount).forEach((p, i) => {
|
||||
if (i % 2 === 0) left.push(p);
|
||||
else right.push(p);
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = [...paintings].sort(comparePaintingsChronological);
|
||||
const mid = Math.ceil(ordered.length / 2);
|
||||
return [
|
||||
orderPaintingsForWallDisplay(back),
|
||||
orderPaintingsForWallDisplay(left),
|
||||
orderPaintingsForWallDisplay(right),
|
||||
[] as Painting[],
|
||||
ordered.slice(0, mid),
|
||||
ordered.slice(mid),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -346,12 +362,13 @@ function layoutWallSlots(
|
||||
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
|
||||
});
|
||||
} else if (side === 'left') {
|
||||
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: Math.PI / 2,
|
||||
side,
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
|
||||
});
|
||||
} else {
|
||||
slots.push({
|
||||
@@ -550,6 +567,7 @@ function CanvasCover({
|
||||
function usePaintingTexture(url: string | null) {
|
||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||
const [failed, setFailed] = useState(!url);
|
||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
@@ -561,11 +579,21 @@ function usePaintingTexture(url: string | null) {
|
||||
setFailed(false);
|
||||
let disposed = false;
|
||||
let loaded: THREE.Texture | null = null;
|
||||
let settled = false;
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
textureLoad?.end();
|
||||
};
|
||||
|
||||
textureLoad?.begin();
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
finish();
|
||||
if (disposed) {
|
||||
tex.dispose();
|
||||
return;
|
||||
@@ -583,16 +611,18 @@ function usePaintingTexture(url: string | null) {
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
finish();
|
||||
if (!disposed) setFailed(true);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
finish();
|
||||
loaded?.dispose();
|
||||
setTexture(null);
|
||||
};
|
||||
}, [url]);
|
||||
}, [url, textureLoad]);
|
||||
|
||||
return { texture, failed };
|
||||
}
|
||||
@@ -1488,6 +1518,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
||||
target.y = EYE_HEIGHT;
|
||||
}
|
||||
|
||||
function FrameloopSync({ active }: { active: boolean }) {
|
||||
const { invalidate } = useThree();
|
||||
useEffect(() => {
|
||||
if (active) invalidate();
|
||||
}, [active, invalidate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CameraController({
|
||||
position,
|
||||
target,
|
||||
@@ -1638,16 +1676,38 @@ export default function VirtualGallery(props: Props) {
|
||||
} = props;
|
||||
|
||||
const isMovement = props.mode === 'movement';
|
||||
const hallKey = isMovement ? props.data.movement.id : props.data.artist.id;
|
||||
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name;
|
||||
const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color;
|
||||
const isTour = props.mode === 'tour';
|
||||
const isWingedHall = isMovement || isTour;
|
||||
const hallKey = isTour
|
||||
? props.data.tour.id
|
||||
: isMovement
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id;
|
||||
const hallTitle = isTour
|
||||
? props.data.tour.title
|
||||
: isMovement
|
||||
? props.data.movement.name
|
||||
: props.data.artist.name;
|
||||
const movementColor = isTour
|
||||
? DEFAULT_MOVEMENT_COLOR
|
||||
: isMovement
|
||||
? props.data.movement.color
|
||||
: props.data.artist.movement_color;
|
||||
const initialPaintings = useMemo(() => {
|
||||
if (props.mode === 'movement') {
|
||||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||||
}
|
||||
return props.data.paintings;
|
||||
}, [props]);
|
||||
const initialPeriods = isMovement ? [] : props.data.periods;
|
||||
}, [
|
||||
props.mode,
|
||||
props.mode === 'tour'
|
||||
? props.data.tour.id
|
||||
: props.mode === 'movement'
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id,
|
||||
props.data.paintings,
|
||||
]);
|
||||
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
|
||||
|
||||
const [paintings, setPaintings] = useState(initialPaintings);
|
||||
const [periods, setPeriods] = useState(initialPeriods);
|
||||
@@ -1658,6 +1718,44 @@ export default function VirtualGallery(props: Props) {
|
||||
const [nearExit, setNearExit] = useState(false);
|
||||
const [nearPassage, setNearPassage] = useState(false);
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
const [texturesPending, setTexturesPending] = useState(0);
|
||||
const [canvasReady, setCanvasReady] = useState(false);
|
||||
const [glEpoch, setGlEpoch] = useState(0);
|
||||
const [glLost, setGlLost] = useState(false);
|
||||
|
||||
const textureLoad = useMemo(
|
||||
() => ({
|
||||
begin: () => setTexturesPending((n) => n + 1),
|
||||
end: () => setTexturesPending((n) => Math.max(0, n - 1)),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||||
const canvas = state.gl.domElement;
|
||||
// A freshly created canvas has a healthy context, so clear any lingering
|
||||
// "restoring" state from a previous loss/remount.
|
||||
setGlLost(false);
|
||||
setCanvasReady(true);
|
||||
const onLost = (event: Event) => {
|
||||
// Prevent the default so the browser can restore the context, and
|
||||
// force a clean remount to obtain a fresh WebGL context if it does not.
|
||||
event.preventDefault();
|
||||
setGlLost(true);
|
||||
window.setTimeout(() => {
|
||||
setGlLost((stillLost) => {
|
||||
if (stillLost) setGlEpoch((n) => n + 1);
|
||||
return stillLost;
|
||||
});
|
||||
}, 600);
|
||||
};
|
||||
const onRestored = () => {
|
||||
setGlLost(false);
|
||||
setGlEpoch((n) => n + 1);
|
||||
};
|
||||
canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
|
||||
canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPaintings(initialPaintings);
|
||||
@@ -1675,32 +1773,13 @@ export default function VirtualGallery(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const artistId = props.data.artist.id;
|
||||
(async () => {
|
||||
try {
|
||||
setSyncStatus('Syncing images…');
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||
await Promise.race([preloadArtistImages(artistId), timeout]);
|
||||
const fresh = await api.getArtist(artistId);
|
||||
if (!cancelled) {
|
||||
setPaintings(fresh.paintings);
|
||||
setPeriods(fresh.periods);
|
||||
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < fresh.paintings.length
|
||||
? `${withImg} of ${fresh.paintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setSyncStatus('');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
|
||||
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < initialPaintings.length
|
||||
? `${withImg} of ${initialPaintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}, [hallKey, props.mode, initialPaintings]);
|
||||
|
||||
const interiorStyle = useMemo(
|
||||
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
|
||||
@@ -1708,8 +1787,8 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const movementHalls = useMemo(
|
||||
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isMovement, paintings]
|
||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isWingedHall, paintings]
|
||||
);
|
||||
|
||||
const [hallIndex, setHallIndex] = useState(0);
|
||||
@@ -1719,18 +1798,26 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (isMovement && movementHalls.length > 0) {
|
||||
if (isWingedHall && movementHalls.length > 0) {
|
||||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||||
}
|
||||
return buildHallLayout(paintings, periods);
|
||||
}, [isMovement, movementHalls, hallIndex, paintings, periods]);
|
||||
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
||||
|
||||
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);
|
||||
|
||||
const galleryLoadingMessage = !canvasReady
|
||||
? 'Loading gallery…'
|
||||
: texturesPending > 0
|
||||
? 'Loading paintings…'
|
||||
: 'Loading gallery…';
|
||||
|
||||
const computedWindows = useMemo(() => {
|
||||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
||||
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
|
||||
}, [isMovement, interiorStyle, layout]);
|
||||
|
||||
const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
|
||||
const halfW = layout.width / 2 - 0.55;
|
||||
const halfD = layout.depth / 2 - 0.35;
|
||||
@@ -1790,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey, initialPos, initialTarget]);
|
||||
|
||||
const openExitNav = useCallback(async () => {
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
setShowExitNav(true);
|
||||
return;
|
||||
}
|
||||
@@ -1804,9 +1891,9 @@ export default function VirtualGallery(props: Props) {
|
||||
} finally {
|
||||
setNavLoading(false);
|
||||
}
|
||||
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]);
|
||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||
|
||||
const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
||||
|
||||
const moveCamera = useCallback(
|
||||
@@ -1831,7 +1918,7 @@ export default function VirtualGallery(props: Props) {
|
||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
||||
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(halfD, target.z));
|
||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
@@ -1851,7 +1938,7 @@ export default function VirtualGallery(props: Props) {
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
},
|
||||
[halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall]
|
||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1868,7 +1955,7 @@ export default function VirtualGallery(props: Props) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
keysPressed.current.add(e.key);
|
||||
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
|
||||
if (isMovement && nearPassage && hasNextHall) {
|
||||
if (isWingedHall && nearPassage && hasNextHall) {
|
||||
goToNextHall();
|
||||
} else {
|
||||
openExitNav();
|
||||
@@ -1896,15 +1983,19 @@ export default function VirtualGallery(props: Props) {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]);
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
|
||||
|
||||
const handleNavigate = (artistId: number) => {
|
||||
if (isMovement) return;
|
||||
if (isWingedHall) return;
|
||||
setShowExitNav(false);
|
||||
props.onNavigateArtist(artistId);
|
||||
};
|
||||
|
||||
const subtitle = isMovement
|
||||
const subtitle = isTour
|
||||
? `Guided tour · ${paintings.length} works${
|
||||
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
|
||||
}`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${interiorStyle.subtitle} · ${paintings.length} works${
|
||||
movementHalls.length > 1
|
||||
@@ -1950,7 +2041,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const exitHint = isMovement ? (
|
||||
const exitHint = isWingedHall ? (
|
||||
<>
|
||||
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
|
||||
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
|
||||
@@ -1960,11 +2051,15 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const hallSubtitle =
|
||||
isMovement && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}`
|
||||
isWingedHall && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length}${
|
||||
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
|
||||
}`
|
||||
: undefined;
|
||||
|
||||
const instructionsTitle = isMovement
|
||||
const instructionsTitle = isTour
|
||||
? `${hallTitle} · Guided tour`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${hallTitle} · ${interiorStyle.label}`
|
||||
: `${hallTitle} Gallery`
|
||||
@@ -1980,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
|
||||
</div>
|
||||
<div className="gallery-header-meta">
|
||||
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
|
||||
{isMovement ? 'Wings / Exit →' : 'Exit →'}
|
||||
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
|
||||
</button>
|
||||
{!isMovement && (
|
||||
{!isWingedHall && (
|
||||
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1996,7 +2091,13 @@ export default function VirtualGallery(props: Props) {
|
||||
onPointerLeave={endCanvasDrag}
|
||||
onPointerCancel={endCanvasDrag}
|
||||
>
|
||||
{syncStatus && (
|
||||
{active && (gallerySceneLoading || glLost) && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
|
||||
/>
|
||||
)}
|
||||
{syncStatus && !gallerySceneLoading && !glLost && (
|
||||
<div className="gallery-loading-overlay gallery-sync-badge">
|
||||
<p>{syncStatus}</p>
|
||||
</div>
|
||||
@@ -2005,10 +2106,14 @@ export default function VirtualGallery(props: Props) {
|
||||
<div className="gallery-exit-hint">{exitHint}</div>
|
||||
)}
|
||||
<Canvas
|
||||
key={`${hallKey}-${glEpoch}`}
|
||||
shadows
|
||||
frameloop={active ? 'always' : 'never'}
|
||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
<FrameloopSync active={active} />
|
||||
<color attach="background" args={[sceneBackground]} />
|
||||
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
||||
<ambientLight intensity={ambientIntensity} />
|
||||
@@ -2017,36 +2122,40 @@ export default function VirtualGallery(props: Props) {
|
||||
intensity={interiorStyle ? 0.85 : 0.65}
|
||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
<ArtistHall
|
||||
<SceneErrorBoundary fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||
<ArtistHall
|
||||
layout={layout}
|
||||
hallTitle={hallTitle}
|
||||
hallSubtitle={hallSubtitle}
|
||||
movementColor={movementColor}
|
||||
interiorStyle={interiorStyle}
|
||||
imageRevisions={imageRevisions}
|
||||
showCaptions={isMovement}
|
||||
showCaptions={isWingedHall}
|
||||
onPaintingClick={onPaintingClick}
|
||||
onExitActivate={openExitNav}
|
||||
nearExit={nearExit}
|
||||
movementMode={isMovement}
|
||||
movementMode={isWingedHall}
|
||||
computedWindows={computedWindows}
|
||||
hasNextHall={hasNextHall}
|
||||
onNextHall={goToNextHall}
|
||||
nearPassage={nearPassage}
|
||||
/>
|
||||
</GalleryTextureLoadContext.Provider>
|
||||
<CameraController position={camPos} target={camTarget} />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{!isMovement && showExitNav && (
|
||||
{!isWingedHall && showExitNav && (
|
||||
<NavigationPanel
|
||||
navigation={navigation}
|
||||
loading={navLoading}
|
||||
@@ -2055,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMovement && showExitNav && (
|
||||
{isWingedHall && showExitNav && (
|
||||
<MovementHallNavPanel
|
||||
movementName={hallTitle}
|
||||
halls={movementHalls}
|
||||
@@ -2083,8 +2192,8 @@ export default function VirtualGallery(props: Props) {
|
||||
<li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
|
||||
<li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
|
||||
<li>Drag on the view to look around</li>
|
||||
<li>Click a painting to view details and influences</li>
|
||||
{isMovement ? (
|
||||
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
|
||||
{isWingedHall ? (
|
||||
<>
|
||||
<li>Date and artist labels appear below each frame</li>
|
||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
|
||||
|
||||
interface AuthContextValue {
|
||||
role: AuthRole;
|
||||
username?: string;
|
||||
isCurator: boolean;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<AuthRole>('user');
|
||||
const [username, setUsername] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const me = await getAuthMe();
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (user: string, password: string) => {
|
||||
const me = await loginCurator(user, password);
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutCurator();
|
||||
setRole('user');
|
||||
setUsername(undefined);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
role,
|
||||
username,
|
||||
isCurator: role === 'curator',
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[role, username, loading, 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;
|
||||
}
|
||||
@@ -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,69 @@
|
||||
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 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';
|
||||
|
||||
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'],
|
||||
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,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
home: ruHome,
|
||||
search: ruSearch,
|
||||
gallery: ruGallery,
|
||||
painting: ruPainting,
|
||||
bio: ruBio,
|
||||
annotations: ruAnnotations,
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
tours: ruTours,
|
||||
},
|
||||
},
|
||||
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,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,26 @@
|
||||
{
|
||||
"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",
|
||||
"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,15 @@
|
||||
{
|
||||
"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}}"
|
||||
}
|
||||
@@ -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,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -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,26 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"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,15 @@
|
||||
{
|
||||
"aboutArtist": "О {{name}}",
|
||||
"influencedBy": "Под влиянием",
|
||||
"influenced": "Влияние на",
|
||||
"noInfluences": "Для этой работы нет задокументированных влияний.",
|
||||
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
|
||||
"catalogPosition": "Позиция в каталоге",
|
||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||
"prevPainting": "Предыдущая картина",
|
||||
"nextPainting": "Следующая картина",
|
||||
"tourNotes": "Текст экскурсии",
|
||||
"tourNotesFor": "Экскурсия · {{title}}",
|
||||
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Поиск",
|
||||
"placeholder": "Художники, картины, направления…",
|
||||
"resultsLabel": "Результаты поиска",
|
||||
"noMatches": "Ничего не найдено.",
|
||||
"groupArtist": "Художники",
|
||||
"groupMovement": "Направления",
|
||||
"groupPainting": "Картины",
|
||||
"searchFailed": "Ошибка поиска"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Экскурсии",
|
||||
"popupTitle": "Экскурсии",
|
||||
"popupHint": "Выберите кураторскую подборку произведений.",
|
||||
"close": "Закрыть",
|
||||
"loading": "Загрузка…",
|
||||
"loadFailed": "Не удалось загрузить экскурсии",
|
||||
"noPublished": "Пока нет опубликованных экскурсий.",
|
||||
"noTours": "Экскурсий пока нет. Создайте первую.",
|
||||
"stopCount": "{{count}} остановок",
|
||||
"back": "← В галерею",
|
||||
"create": "Создать",
|
||||
"newTourPlaceholder": "Название новой экскурсии…",
|
||||
"selectTour": "Выберите экскурсию для редактирования.",
|
||||
"tourTitle": "Название",
|
||||
"tourDescription": "Описание",
|
||||
"status": "Статус",
|
||||
"draft": "Черновик",
|
||||
"published": "Опубликовано",
|
||||
"saveMeta": "Сохранить сведения",
|
||||
"delete": "Удалить экскурсию",
|
||||
"confirmDelete": "Удалить эту экскурсию и все остановки?",
|
||||
"searchPainting": "Поиск картин для добавления…",
|
||||
"saveStops": "Сохранить остановки",
|
||||
"remove": "Убрать",
|
||||
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
|
||||
"noStops": "Остановок пока нет. Найдите и добавьте картины."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Проверка переводов",
|
||||
"back": "← В галерею",
|
||||
"locale": "Язык",
|
||||
"entityType": "Тип объекта",
|
||||
"status": "Статус",
|
||||
"all": "Все",
|
||||
"draft": "Черновик",
|
||||
"reviewed": "Проверено",
|
||||
"published": "Опубликовано",
|
||||
"coverage": "Охват",
|
||||
"artistsBio": "Художники с биографией (ru)",
|
||||
"paintingsTitle": "Картины с русским названием",
|
||||
"canonical": "Английский (оригинал)",
|
||||
"translation": "Перевод",
|
||||
"saveDraft": "Сохранить черновик",
|
||||
"publish": "Опубликовать",
|
||||
"noRows": "Нет строк по выбранному фильтру.",
|
||||
"loadFailed": "Не удалось загрузить переводы"
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './context/AuthContext.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -27,10 +27,20 @@
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-session-active {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-overlay {
|
||||
@@ -44,6 +54,8 @@
|
||||
padding: 24px 16px 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 110;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-dev-tools {
|
||||
@@ -56,7 +68,9 @@
|
||||
}
|
||||
|
||||
.debug-mode-toggle,
|
||||
.checkup-link-btn {
|
||||
.checkup-link-btn,
|
||||
.curator-login-btn,
|
||||
.curator-logout-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
@@ -69,7 +83,9 @@
|
||||
}
|
||||
|
||||
.debug-mode-toggle:hover,
|
||||
.checkup-link-btn:hover {
|
||||
.checkup-link-btn:hover,
|
||||
.curator-login-btn:hover,
|
||||
.curator-logout-btn:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
@@ -116,6 +132,25 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.curator-session-label {
|
||||
font-size: 11px;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.curator-login-btn {
|
||||
border-color: rgba(255, 220, 160, 0.35);
|
||||
}
|
||||
|
||||
.curator-logout-btn {
|
||||
border-color: rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
}
|
||||
|
||||
.checkup-link-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
@@ -6,8 +7,33 @@ import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import ToursPage from '../pages/ToursPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import ToursPopup from '../components/ToursPopup';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import '../components/ToursPopup.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import '../pages/ToursPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type {
|
||||
TimelineData,
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
Painting,
|
||||
PaintingDetail,
|
||||
MovementGalleryDetail,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
|
||||
import './HomePage.css';
|
||||
@@ -15,14 +41,21 @@ import './HomePage.css';
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
| { type: 'tours' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||
|
||||
type GallerySession =
|
||||
| { kind: 'artist'; artistId: number; data: ArtistDetail }
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
@@ -35,6 +68,17 @@ function patchPaintingInMovementDetail(
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInTourDetail(
|
||||
detail: TourGalleryDetail,
|
||||
paintingId: number,
|
||||
patch: Partial<Painting>
|
||||
): TourGalleryDetail {
|
||||
return {
|
||||
...detail,
|
||||
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInArtistDetail(
|
||||
detail: ArtistDetail,
|
||||
paintingId: number,
|
||||
@@ -56,7 +100,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
|
||||
function patchReturnToAfterRemove(
|
||||
returnTo: View,
|
||||
freshArtist?: ArtistDetail,
|
||||
freshMovement?: MovementGalleryDetail
|
||||
freshMovement?: MovementGalleryDetail,
|
||||
freshTour?: TourGalleryDetail
|
||||
): View {
|
||||
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
|
||||
return { ...returnTo, data: freshArtist };
|
||||
@@ -68,8 +113,14 @@ function patchReturnToAfterRemove(
|
||||
) {
|
||||
return { ...returnTo, data: freshMovement };
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
|
||||
return { ...returnTo, data: freshTour };
|
||||
}
|
||||
if (returnTo.type === 'painting') {
|
||||
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
|
||||
return {
|
||||
...returnTo,
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'bio') {
|
||||
const data =
|
||||
@@ -77,7 +128,7 @@ function patchReturnToAfterRemove(
|
||||
return {
|
||||
...returnTo,
|
||||
data,
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
|
||||
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
|
||||
};
|
||||
}
|
||||
return returnTo;
|
||||
@@ -95,6 +146,8 @@ function catalogNavigateTarget(
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout } = useAuth();
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
@@ -103,12 +156,19 @@ export default function HomePage() {
|
||||
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
const [galleryEntryLoading, setGalleryEntryLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
||||
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
@@ -119,53 +179,76 @@ export default function HomePage() {
|
||||
} | null>(null);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
useEffect(() => {
|
||||
api.getBounds()
|
||||
.then((b) => {
|
||||
const min = b.min_year ?? -800;
|
||||
const max = b.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type === 'gallery') {
|
||||
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
|
||||
} else if (view.type === 'movement-gallery') {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'tour-gallery') {
|
||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
const loadTimelineData = useCallback(async (start: number, end: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [timeline, artistList] = await Promise.all([
|
||||
api.getTimeline(start, end),
|
||||
api.getArtists(start, end),
|
||||
]);
|
||||
setTimelineData(timeline);
|
||||
setArtists(artistList);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Load full catalog once — pan/zoom filters client-side (MovementBands, Timeline).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const catalog = await api.getCatalogBootstrap();
|
||||
if (cancelled) return;
|
||||
|
||||
const min = catalog.bounds.min_year ?? -800;
|
||||
const max = catalog.bounds.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
|
||||
setArtists(catalog.artists);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!cancelled) setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localeVersion]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
|
||||
setApiLocale(locale);
|
||||
setLocaleVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimelineData(viewStart, viewEnd);
|
||||
}, [viewStart, viewEnd, loadTimelineData]);
|
||||
const viewChangeScheduler = useRef(
|
||||
createViewChangeScheduler((start, end) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
})
|
||||
);
|
||||
|
||||
const handleViewChange = (start: number, end: number) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
};
|
||||
useEffect(() => () => viewChangeScheduler.current.cancel(), []);
|
||||
|
||||
const handleViewChange = useCallback((start: number, end: number) => {
|
||||
viewChangeScheduler.current.schedule(start, end);
|
||||
}, []);
|
||||
|
||||
const goToTimelineHome = useCallback(() => {
|
||||
detailReturnToRef.current = { type: 'timeline' };
|
||||
setGallerySession(null);
|
||||
setHoveredLifespan(null);
|
||||
setGalleryEntryLoading(null);
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
@@ -180,11 +263,79 @@ export default function HomePage() {
|
||||
writeDebugShowMore(enabled);
|
||||
};
|
||||
|
||||
const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
|
||||
setLoginRedirect(redirect);
|
||||
setLoginOpen(true);
|
||||
};
|
||||
|
||||
const handleCuratorLogin = async (user: string, password: string) => {
|
||||
await login(user, password);
|
||||
setLoginOpen(false);
|
||||
if (loginRedirect === 'checkup') {
|
||||
setView({ type: 'checkup' });
|
||||
} else if (loginRedirect === 'translations') {
|
||||
setView({ type: 'translations' });
|
||||
} else if (loginRedirect === 'influences') {
|
||||
setView({ type: 'influences' });
|
||||
} else if (loginRedirect === 'tours') {
|
||||
setView({ type: 'tours' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
|
||||
const handleCuratorLogout = async () => {
|
||||
await logout();
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (
|
||||
view.type === 'checkup' ||
|
||||
view.type === 'translations' ||
|
||||
view.type === 'influences' ||
|
||||
view.type === 'tours'
|
||||
) {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
|
||||
const openCheckup = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('checkup');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'checkup' });
|
||||
};
|
||||
|
||||
const openTranslations = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('translations');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'translations' });
|
||||
};
|
||||
|
||||
const openInfluences = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('influences');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'influences' });
|
||||
};
|
||||
|
||||
const openToursEditor = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('tours');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'tours' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
image_path: fixResult.imagePath,
|
||||
thumbnail_path: fixResult.thumbnailPath,
|
||||
image_cache_key: fixResult.image_cache_key ?? null,
|
||||
thumbnail_cache_key: fixResult.thumbnail_cache_key ?? null,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
@@ -210,6 +361,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -224,6 +381,9 @@ export default function HomePage() {
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'tour') {
|
||||
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}, []);
|
||||
@@ -255,6 +415,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -269,6 +435,9 @@ export default function HomePage() {
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'tour') {
|
||||
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
},
|
||||
@@ -312,6 +481,9 @@ export default function HomePage() {
|
||||
const data = await api.getArtist(artistId);
|
||||
const patch: Partial<Artist> = {
|
||||
portrait_path: fixResult.portraitPath,
|
||||
portrait_thumb_path: fixResult.portraitThumbPath ?? null,
|
||||
portrait_cache_key: fixResult.portrait_cache_key ?? null,
|
||||
portrait_thumb_cache_key: fixResult.portrait_thumb_cache_key ?? null,
|
||||
checkup_checked: fixResult.checked ?? true,
|
||||
checkup_fixed: fixResult.fixed ?? true,
|
||||
};
|
||||
@@ -342,23 +514,66 @@ export default function HomePage() {
|
||||
[applyArtistPatch]
|
||||
);
|
||||
|
||||
const openArtistGallery = useCallback((artistId: number, data: ArtistDetail) => {
|
||||
const session: GallerySession = { kind: 'artist', artistId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
}, []);
|
||||
|
||||
const openMovementGallery = useCallback((movementId: number, data: MovementGalleryDetail) => {
|
||||
const session: GallerySession = { kind: 'movement', movementId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
}, []);
|
||||
|
||||
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
|
||||
const session: GallerySession = { kind: 'tour', tourId, data };
|
||||
setGallerySession(session);
|
||||
setView({ type: 'tour-gallery', tourId, data });
|
||||
}, []);
|
||||
|
||||
const handleSelectPublishedTour = useCallback(
|
||||
async (tourId: number) => {
|
||||
setToursPopupOpen(false);
|
||||
setGalleryEntryLoading(t('openingTourGallery'));
|
||||
try {
|
||||
const data = await api.getTour(tourId);
|
||||
if (!data.paintings.length) {
|
||||
setError(t('tourEmpty'));
|
||||
return;
|
||||
}
|
||||
openTourGallery(tourId, data);
|
||||
} catch {
|
||||
setError(t('tourLoadFailed'));
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
},
|
||||
[openTourGallery, t]
|
||||
);
|
||||
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
setGalleryEntryLoading('Opening artist gallery…');
|
||||
try {
|
||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
||||
const data = await api.getArtist(artistId);
|
||||
setGallerySession({ kind: 'artist', artistId, data });
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
openArtistGallery(artistId, data);
|
||||
} catch {
|
||||
setError('Failed to load artist gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMovementClick = async (movementId: number) => {
|
||||
setGalleryEntryLoading('Opening movement gallery…');
|
||||
try {
|
||||
const data = await api.getMovementGallery(movementId);
|
||||
setGallerySession({ kind: 'movement', movementId, data });
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
openMovementGallery(movementId, data);
|
||||
} catch {
|
||||
setError('Failed to load movement gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -399,25 +614,38 @@ export default function HomePage() {
|
||||
const currentView = viewRef.current;
|
||||
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
|
||||
|
||||
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
const sorted =
|
||||
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
|
||||
? detailArtistPaintings
|
||||
: sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
const nextId = catalogNavigateTarget(sorted, paintingId);
|
||||
const inMovementCatalog =
|
||||
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
|
||||
const inTourCatalog =
|
||||
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
|
||||
|
||||
await api.deletePainting(paintingId);
|
||||
|
||||
const freshArtist = await api.getArtist(artistId);
|
||||
let freshMovement: MovementGalleryDetail | undefined;
|
||||
let freshTour: TourGalleryDetail | undefined;
|
||||
if (gallerySession?.kind === 'movement') {
|
||||
freshMovement = await api.getMovementGallery(gallerySession.movementId);
|
||||
} else if (currentView.returnTo.type === 'movement-gallery') {
|
||||
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
|
||||
}
|
||||
if (gallerySession?.kind === 'tour') {
|
||||
freshTour = await api.getTour(gallerySession.tourId);
|
||||
} else if (currentView.returnTo.type === 'tour-gallery') {
|
||||
freshTour = await api.getTour(currentView.returnTo.tourId);
|
||||
}
|
||||
|
||||
const freshCatalog =
|
||||
inMovementCatalog && freshMovement
|
||||
? sortArtistPaintingsChronological(freshMovement.paintings)
|
||||
: sortArtistPaintingsChronological(freshArtist.paintings);
|
||||
inTourCatalog && freshTour
|
||||
? freshTour.paintings
|
||||
: inMovementCatalog && freshMovement
|
||||
? sortArtistPaintingsChronological(freshMovement.paintings)
|
||||
: sortArtistPaintingsChronological(freshArtist.paintings);
|
||||
|
||||
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
|
||||
const navigateId =
|
||||
@@ -438,6 +666,9 @@ export default function HomePage() {
|
||||
if (session.kind === 'movement' && freshMovement) {
|
||||
return { ...session, data: freshMovement };
|
||||
}
|
||||
if (session.kind === 'tour' && freshTour) {
|
||||
return { ...session, data: freshTour };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
@@ -452,7 +683,8 @@ export default function HomePage() {
|
||||
const patchedReturnTo = patchReturnToAfterRemove(
|
||||
currentView.returnTo,
|
||||
freshArtist,
|
||||
freshMovement
|
||||
freshMovement,
|
||||
freshTour
|
||||
);
|
||||
detailReturnToRef.current = patchedReturnTo;
|
||||
|
||||
@@ -463,6 +695,9 @@ export default function HomePage() {
|
||||
if (current.type === 'movement-gallery' && freshMovement) {
|
||||
return { ...current, data: freshMovement };
|
||||
}
|
||||
if (current.type === 'tour-gallery' && freshTour) {
|
||||
return { ...current, data: freshTour };
|
||||
}
|
||||
if (current.type !== 'painting' || current.paintingId !== paintingId) {
|
||||
return current;
|
||||
}
|
||||
@@ -494,12 +729,20 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const artistId = view.data.painting.artist_id;
|
||||
if (gallerySession?.kind === 'tour') {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = detailReturnToRef.current;
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
setDetailArtistPaintings(returnTo.data.paintings);
|
||||
return;
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
|
||||
return;
|
||||
@@ -519,44 +762,89 @@ export default function HomePage() {
|
||||
};
|
||||
}, [view, gallerySession]);
|
||||
|
||||
const sortedDetailArtistPaintings = useMemo(
|
||||
() => sortArtistPaintingsChronological(detailArtistPaintings),
|
||||
[detailArtistPaintings]
|
||||
);
|
||||
const sortedDetailArtistPaintings = useMemo(() => {
|
||||
const fromTourSession = gallerySession?.kind === 'tour';
|
||||
const fromTourReturn =
|
||||
view.type === 'painting' && view.returnTo.type === 'tour-gallery';
|
||||
if (fromTourSession || fromTourReturn) {
|
||||
return detailArtistPaintings;
|
||||
}
|
||||
return sortArtistPaintingsChronological(detailArtistPaintings);
|
||||
}, [detailArtistPaintings, gallerySession, view]);
|
||||
|
||||
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
|
||||
const tourOverlay =
|
||||
view.type === 'painting' && gallerySession?.kind === 'tour'
|
||||
? {
|
||||
title: gallerySession.data.tour.title,
|
||||
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
|
||||
}
|
||||
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
|
||||
? {
|
||||
title: view.returnTo.data.tour.title,
|
||||
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
|
||||
}
|
||||
: null;
|
||||
|
||||
const galleryActive =
|
||||
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
|
||||
|
||||
const displayGallery = useMemo((): GallerySession | null => {
|
||||
if (view.type === 'gallery') {
|
||||
return { kind: 'artist', artistId: view.artistId, data: view.data };
|
||||
}
|
||||
if (view.type === 'movement-gallery') {
|
||||
return { kind: 'movement', movementId: view.movementId, data: view.data };
|
||||
}
|
||||
if (view.type === 'tour-gallery') {
|
||||
return { kind: 'tour', tourId: view.tourId, data: view.data };
|
||||
}
|
||||
return gallerySession;
|
||||
}, [view, gallerySession]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{gallerySession && (
|
||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||
{gallerySession.kind === 'artist' ? (
|
||||
{displayGallery && (
|
||||
<div
|
||||
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
|
||||
aria-hidden={!galleryActive}
|
||||
>
|
||||
{displayGallery.kind === 'artist' ? (
|
||||
<VirtualGallery
|
||||
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}`}
|
||||
mode="artist"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
onBioClick={() =>
|
||||
handleBioClick(gallerySession.data, {
|
||||
handleBioClick(displayGallery.data, {
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
artistId: displayGallery.artistId,
|
||||
data: displayGallery.data,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : displayGallery.kind === 'movement' ? (
|
||||
<VirtualGallery
|
||||
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
|
||||
mode="movement"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
/>
|
||||
) : (
|
||||
<VirtualGallery
|
||||
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
|
||||
mode="tour"
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={goToTimelineHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -568,28 +856,39 @@ export default function HomePage() {
|
||||
key={view.paintingId}
|
||||
data={view.data}
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
|
||||
tourTitle={tourOverlay?.title ?? null}
|
||||
tourText={tourOverlay ? tourOverlay.text : null}
|
||||
onBack={() => {
|
||||
if (view.returnTo.type === 'timeline') {
|
||||
goToTimelineHome();
|
||||
return;
|
||||
}
|
||||
const returnTo = view.returnTo;
|
||||
if (
|
||||
returnTo.type === 'gallery' &&
|
||||
gallerySession?.kind === 'artist' &&
|
||||
gallerySession.artistId === returnTo.artistId
|
||||
) {
|
||||
setView({
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openArtistGallery(gallerySession.artistId, gallerySession.data);
|
||||
} else if (
|
||||
returnTo.type === 'movement-gallery' &&
|
||||
gallerySession?.kind === 'movement' &&
|
||||
gallerySession.movementId === returnTo.movementId
|
||||
) {
|
||||
setView({
|
||||
type: 'movement-gallery',
|
||||
movementId: gallerySession.movementId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openMovementGallery(gallerySession.movementId, gallerySession.data);
|
||||
} else if (
|
||||
returnTo.type === 'tour-gallery' &&
|
||||
gallerySession?.kind === 'tour' &&
|
||||
gallerySession.tourId === returnTo.tourId
|
||||
) {
|
||||
openTourGallery(gallerySession.tourId, gallerySession.data);
|
||||
} else if (returnTo.type === 'gallery') {
|
||||
openArtistGallery(returnTo.artistId, returnTo.data);
|
||||
} else if (returnTo.type === 'movement-gallery') {
|
||||
openMovementGallery(returnTo.movementId, returnTo.data);
|
||||
} else if (returnTo.type === 'tour-gallery') {
|
||||
openTourGallery(returnTo.tourId, returnTo.data);
|
||||
} else {
|
||||
setView(returnTo);
|
||||
}
|
||||
@@ -601,8 +900,8 @@ export default function HomePage() {
|
||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
@@ -614,12 +913,12 @@ export default function HomePage() {
|
||||
<div className="home-overlay">
|
||||
<ArtistBio
|
||||
artist={view.data.artist}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||
onBack={() => setView(view.returnTo)}
|
||||
onEnterGallery={() =>
|
||||
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
||||
openArtistGallery(view.artistId, view.data)
|
||||
}
|
||||
onArtistPortraitFixed={handleArtistPortraitFixed}
|
||||
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
||||
@@ -627,50 +926,209 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
<CheckupPage
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
{view.type === 'influences' && (
|
||||
isCurator ? (
|
||||
<InfluencesPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('influences')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'tours' && (
|
||||
isCurator ? (
|
||||
<ToursPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
onBack={goToTimelineHome}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>Curator access required</h2>
|
||||
<p>The painting checkup table is available to logged-in curators only.</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||
Curator login
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
Back to gallery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<CuratorLoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => {
|
||||
setLoginOpen(false);
|
||||
setLoginRedirect(null);
|
||||
}}
|
||||
onLogin={handleCuratorLogin}
|
||||
/>
|
||||
|
||||
<ToursPopup
|
||||
open={toursPopupOpen}
|
||||
onClose={() => setToursPopupOpen(false)}
|
||||
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
|
||||
/>
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-dev-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
{isCurator ? (
|
||||
<>
|
||||
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||
{username}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openInfluences}
|
||||
title="Manage influence links"
|
||||
>
|
||||
{t('influences')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openToursEditor}
|
||||
title="Create and edit guided tours"
|
||||
>
|
||||
{t('toursEditor')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openTranslations}
|
||||
title="Review and publish Russian translations"
|
||||
>
|
||||
{t('translations')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openCheckup}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
{t('checkup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="curator-logout-btn"
|
||||
onClick={handleCuratorLogout}
|
||||
title="Sign out curator session"
|
||||
>
|
||||
{t('curatorLogout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="curator-login-btn"
|
||||
onClick={() => openCuratorLogin()}
|
||||
title="Sign in as curator to use debug tools"
|
||||
>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={() => setView({ type: 'checkup' })}
|
||||
title="Open painting image checkup table"
|
||||
onClick={() => setToursPopupOpen(true)}
|
||||
title={t('tours')}
|
||||
>
|
||||
Checkup
|
||||
{t('tours')}
|
||||
</button>
|
||||
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="site-subtitle">{t('subtitle')}</p>
|
||||
<CatalogSearchBar
|
||||
onSelectArtist={handleArtistClick}
|
||||
onSelectMovement={handleMovementClick}
|
||||
onSelectPainting={handlePaintingClick}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
{galleryEntryLoading && (
|
||||
<GalleryLoadingMarker overlay message={galleryEntryLoading} />
|
||||
)}
|
||||
{portraitsLoading && !loading && !galleryEntryLoading && (
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
@@ -683,9 +1141,7 @@ export default function HomePage() {
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
{!loading && (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
@@ -701,6 +1157,7 @@ export default function HomePage() {
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
onPortraitsLoadingChange={setPortraitsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
.influences-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.influences-loading-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(10, 10, 20, 0.72);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.influences-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.influences-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
flex: 1;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-back {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-back:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-tabs button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-tabs button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs button.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
color: #e8d5b5;
|
||||
border-color: #e8a040;
|
||||
}
|
||||
|
||||
.influences-error {
|
||||
background: rgba(139, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||
color: #ffaaaa;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.influences-success {
|
||||
background: rgba(22, 101, 52, 0.35);
|
||||
border: 1px solid rgba(134, 239, 172, 0.35);
|
||||
color: #bbf7d0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.influences-dup-warn {
|
||||
background: rgba(120, 53, 15, 0.45);
|
||||
border: 1px solid rgba(251, 191, 36, 0.45);
|
||||
color: #fde68a;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.influences-dup-warn p {
|
||||
margin: 0;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.influences-force {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #e8d5b5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-force input {
|
||||
accent-color: #e8a040;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.influences-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.influences-toolbar input[type='search'],
|
||||
.influences-add input,
|
||||
.influences-add textarea,
|
||||
.influences-add select,
|
||||
.wizard-block select,
|
||||
.wizard-block input {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
min-width: 12rem;
|
||||
font: inherit;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar input::placeholder,
|
||||
.influences-add input::placeholder,
|
||||
.wizard-block input::placeholder {
|
||||
color: rgba(201, 169, 110, 0.5);
|
||||
}
|
||||
|
||||
.influences-toolbar button,
|
||||
.wizard-actions button,
|
||||
.influences-add button,
|
||||
.influences-hits button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.influences-toolbar button:hover,
|
||||
.wizard-actions button:hover,
|
||||
.influences-add button:hover,
|
||||
.influences-hits button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-meta {
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-add {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 15, 26, 0.55);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-add label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-hits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.influences-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th,
|
||||
.influences-table td {
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.15);
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th {
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 18rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e8a040;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkish:hover {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ffaaaa !important;
|
||||
border-color: rgba(255, 170, 170, 0.4) !important;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wizard-steps li {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-steps li.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block p,
|
||||
.wizard-block label,
|
||||
.wizard-block summary {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.warnings-list {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
font-size: 0.85rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.mapping-table select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.influences-graph h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.15rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
height: auto;
|
||||
background: rgba(15, 15, 26, 0.65);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.influences-svg .edge-in {
|
||||
stroke: rgba(201, 169, 110, 0.55);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .edge-out {
|
||||
stroke: #e8a040;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .node circle {
|
||||
fill: rgba(201, 169, 110, 0.35);
|
||||
stroke: #c9a96e;
|
||||
stroke-width: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-svg .node-artist circle {
|
||||
fill: rgba(96, 165, 250, 0.35);
|
||||
stroke: #93c5fd;
|
||||
}
|
||||
|
||||
.influences-svg .node-movement circle {
|
||||
fill: rgba(74, 222, 128, 0.3);
|
||||
stroke: #86efac;
|
||||
}
|
||||
|
||||
.influences-svg .node-painting circle {
|
||||
fill: rgba(251, 146, 60, 0.3);
|
||||
stroke: #fdba74;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus circle {
|
||||
fill: #e8a040;
|
||||
stroke: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg .node text {
|
||||
font-size: 10px;
|
||||
fill: #e8d5b5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus text {
|
||||
font-weight: 650;
|
||||
fill: #e8d5b5;
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type InfluenceEdgeItem,
|
||||
type InfluenceGraph,
|
||||
type InfluenceImportParseResult,
|
||||
type InfluenceImportPreview,
|
||||
type InfluenceImportProposal,
|
||||
} from '../api/client';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import './InfluencesPage.css';
|
||||
|
||||
type Tab = 'list' | 'import' | 'graph';
|
||||
type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'ignore',
|
||||
'subject_artist',
|
||||
'subject_painting',
|
||||
'influenced_by',
|
||||
'influenced',
|
||||
'notes',
|
||||
'reference',
|
||||
'source_url',
|
||||
] as const;
|
||||
|
||||
export default function InfluencesPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('influences');
|
||||
const [tab, setTab] = useState<Tab>('list');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// List
|
||||
const [items, setItems] = useState<InfluenceEdgeItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterArtistId, setFilterArtistId] = useState<number | null>(null);
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addPaintingQuery, setAddPaintingQuery] = useState('');
|
||||
const [addSourceQuery, setAddSourceQuery] = useState('');
|
||||
const [addSourceType, setAddSourceType] = useState<'artist' | 'painting' | 'movement'>('artist');
|
||||
const [addPaintingId, setAddPaintingId] = useState<number | null>(null);
|
||||
const [addSourceId, setAddSourceId] = useState<number | null>(null);
|
||||
const [addNotes, setAddNotes] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
const [sourceHits, setSourceHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
|
||||
// Graph
|
||||
const [graphArtistQuery, setGraphArtistQuery] = useState('');
|
||||
const [graphArtistId, setGraphArtistId] = useState<number | null>(null);
|
||||
const [graphArtistHits, setGraphArtistHits] = useState<Array<{ id: number; label: string }>>([]);
|
||||
const [graph, setGraph] = useState<InfluenceGraph | null>(null);
|
||||
|
||||
// Import wizard
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>('upload');
|
||||
const [parseResult, setParseResult] = useState<InfluenceImportParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [selectedSheet, setSelectedSheet] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<InfluenceImportPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyMessage, setBusyMessage] = useState('');
|
||||
const [commitResult, setCommitResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
||||
const [forceImport, setForceImport] = useState(false);
|
||||
|
||||
const startBusy = (message: string) => {
|
||||
setBusyMessage(message);
|
||||
setBusy(true);
|
||||
};
|
||||
|
||||
const stopBusy = () => {
|
||||
setBusy(false);
|
||||
setBusyMessage('');
|
||||
};
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listInfluences({
|
||||
q: q || undefined,
|
||||
artistId: filterArtistId || undefined,
|
||||
limit: 200,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, filterArtistId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'list') void loadList();
|
||||
}, [tab, loadList]);
|
||||
|
||||
const runSearch = async (query: string, types: string) => {
|
||||
if (query.trim().length < 2) return [] as Array<{ type: string; id: number; label: string }>;
|
||||
const data = await api.search(query.trim(), { types, limit: 12 });
|
||||
return data.results.map((r) => {
|
||||
if (r.type === 'painting') {
|
||||
return { type: r.type, id: r.id, label: `${r.artist_name} — ${r.title}` };
|
||||
}
|
||||
return { type: r.type, id: r.id, label: r.name };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSearchHits(await runSearch(addPaintingQuery, 'painting'));
|
||||
} catch {
|
||||
setSearchHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addPaintingQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSourceHits(await runSearch(addSourceQuery, addSourceType));
|
||||
} catch {
|
||||
setSourceHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addSourceQuery, addSourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const hits = await runSearch(graphArtistQuery, 'artist');
|
||||
setGraphArtistHits(hits.filter((h) => h.type === 'artist').map((h) => ({ id: h.id, label: h.label })));
|
||||
} catch {
|
||||
setGraphArtistHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [graphArtistQuery]);
|
||||
|
||||
const loadGraph = async (artistId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getInfluenceGraph({ artistId });
|
||||
setGraph(data);
|
||||
setGraphArtistId(artistId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
try {
|
||||
await api.deleteInfluence(id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!addPaintingId || !addSourceId) {
|
||||
setError(t('addRequiresIds'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingSave'));
|
||||
setError(null);
|
||||
try {
|
||||
const payload: Parameters<typeof api.createInfluence>[0] = {
|
||||
paintingId: addPaintingId,
|
||||
sourceType: addSourceType,
|
||||
notes: addNotes || undefined,
|
||||
source: 'curator-ui',
|
||||
};
|
||||
if (addSourceType === 'artist') payload.sourceArtistId = addSourceId;
|
||||
if (addSourceType === 'painting') payload.sourcePaintingId = addSourceId;
|
||||
if (addSourceType === 'movement') payload.sourceMovementId = addSourceId;
|
||||
await api.createInfluence(payload);
|
||||
setShowAdd(false);
|
||||
setAddPaintingId(null);
|
||||
setAddSourceId(null);
|
||||
setAddNotes('');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChosen = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setPendingFile(file);
|
||||
startBusy(t('loadingParse'));
|
||||
setError(null);
|
||||
setCommitResult(null);
|
||||
setForceImport(false);
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(file);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(parsed.sheet);
|
||||
setWizardStep('mapping');
|
||||
setPreview(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const reparseSheet = async (sheet: string) => {
|
||||
if (!pendingFile) return;
|
||||
startBusy(t('loadingParse'));
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(pendingFile, sheet);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(sheet);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreset = (presetId: string) => {
|
||||
if (!parseResult) return;
|
||||
const preset = parseResult.presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const col of parseResult.columns) next[col] = 'ignore';
|
||||
for (const [col, role] of Object.entries(preset.mapping)) {
|
||||
if (parseResult.columns.includes(col)) next[col] = role;
|
||||
}
|
||||
// Fill gaps with auto suggestions
|
||||
for (const [col, role] of Object.entries(parseResult.suggestedMapping)) {
|
||||
if (next[col] === 'ignore' && role !== 'ignore') next[col] = role;
|
||||
}
|
||||
setMapping(next);
|
||||
};
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!parseResult?.rows) {
|
||||
setError(t('rowsMissing'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingPreview'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.previewInfluenceImport({
|
||||
rows: parseResult.rows,
|
||||
mapping,
|
||||
sourceLabel: parseResult.filename,
|
||||
contentHash: parseResult.contentHash,
|
||||
payloadHash: parseResult.payloadHash,
|
||||
});
|
||||
setPreview(result);
|
||||
setWizardStep('preview');
|
||||
if (result.alreadyImported) setForceImport(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('previewFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const runCommit = async () => {
|
||||
if (!preview) return;
|
||||
const already = preview.alreadyImported || parseResult?.alreadyImported;
|
||||
if (already && !forceImport) {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingCommit'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.commitInfluenceImport({
|
||||
proposals: preview.proposals,
|
||||
fileName: parseResult?.filename,
|
||||
contentHash: preview.contentHash || parseResult?.contentHash,
|
||||
payloadHash: preview.payloadHash || parseResult?.payloadHash,
|
||||
force: forceImport,
|
||||
});
|
||||
setCommitResult(result);
|
||||
setWizardStep('done');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
if (e.code === 'ALREADY_IMPORTED') {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('commitFailed'));
|
||||
}
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const priorImport = preview?.priorImport || parseResult?.priorImport || null;
|
||||
const alreadyImported = Boolean(preview?.alreadyImported || parseResult?.alreadyImported);
|
||||
|
||||
const createProposals = useMemo(
|
||||
() => (preview?.proposals || []).filter((p) => p.action === 'create').slice(0, 200),
|
||||
[preview],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="influences-page">
|
||||
{busy && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="influences-loading-overlay"
|
||||
message={busyMessage || t('loading')}
|
||||
/>
|
||||
)}
|
||||
<header className="influences-header">
|
||||
<button type="button" className="influences-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
<nav className="influences-tabs">
|
||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||
{t('tabList')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'import' ? 'active' : ''} onClick={() => setTab('import')}>
|
||||
{t('tabImport')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'graph' ? 'active' : ''} onClick={() => setTab('graph')}>
|
||||
{t('tabGraph')}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{error && <div className="influences-error">{error}</div>}
|
||||
|
||||
{tab === 'list' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
<button type="button" onClick={() => void loadList()} disabled={loading}>
|
||||
{loading ? t('loading') : t('refresh')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowAdd((v) => !v)}>
|
||||
{showAdd ? t('cancelAdd') : t('addEdge')}
|
||||
</button>
|
||||
<span className="influences-meta">
|
||||
{t('total', { count: total })}
|
||||
{filterArtistId ? ` · ${t('filtered')}` : ''}
|
||||
</span>
|
||||
{filterArtistId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilterArtistId(null);
|
||||
}}
|
||||
>
|
||||
{t('clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="influences-add">
|
||||
<label>
|
||||
{t('subjectPainting')}
|
||||
<input
|
||||
value={addPaintingQuery}
|
||||
onChange={(e) => {
|
||||
setAddPaintingQuery(e.target.value);
|
||||
setAddPaintingId(null);
|
||||
}}
|
||||
placeholder={t('searchPainting')}
|
||||
/>
|
||||
</label>
|
||||
{searchHits.length > 0 && !addPaintingId && (
|
||||
<ul className="influences-hits">
|
||||
{searchHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddPaintingId(h.id);
|
||||
setAddPaintingQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('sourceType')}
|
||||
<select
|
||||
value={addSourceType}
|
||||
onChange={(e) => {
|
||||
setAddSourceType(e.target.value as 'artist' | 'painting' | 'movement');
|
||||
setAddSourceId(null);
|
||||
setAddSourceQuery('');
|
||||
}}
|
||||
>
|
||||
<option value="artist">{t('typeArtist')}</option>
|
||||
<option value="painting">{t('typePainting')}</option>
|
||||
<option value="movement">{t('typeMovement')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('sourceEntity')}
|
||||
<input
|
||||
value={addSourceQuery}
|
||||
onChange={(e) => {
|
||||
setAddSourceQuery(e.target.value);
|
||||
setAddSourceId(null);
|
||||
}}
|
||||
placeholder={t('searchSource')}
|
||||
/>
|
||||
</label>
|
||||
{sourceHits.length > 0 && !addSourceId && (
|
||||
<ul className="influences-hits">
|
||||
{sourceHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddSourceId(h.id);
|
||||
setAddSourceQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('notes')}
|
||||
<textarea value={addNotes} onChange={(e) => setAddNotes(e.target.value)} rows={2} />
|
||||
</label>
|
||||
<button type="button" disabled={busy} onClick={() => void handleCreate()}>
|
||||
{t('saveEdge')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colNotes')}</th>
|
||||
<th>{t('colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => {
|
||||
setFilterArtistId(item.artistId);
|
||||
setTab('graph');
|
||||
void loadGraph(item.artistId);
|
||||
}}
|
||||
>
|
||||
{item.artistName}
|
||||
</button>
|
||||
<div className="muted">{item.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{item.sourceLabel || '—'}</td>
|
||||
<td>{item.sourceType}</td>
|
||||
<td className="notes-cell">{item.notes || '—'}</td>
|
||||
<td>
|
||||
<button type="button" className="danger" onClick={() => void handleDelete(item.id)}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>{t('noEdges')}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<section className="influences-panel">
|
||||
<ol className="wizard-steps">
|
||||
<li className={wizardStep === 'upload' ? 'active' : ''}>{t('stepUpload')}</li>
|
||||
<li className={wizardStep === 'mapping' ? 'active' : ''}>{t('stepMapping')}</li>
|
||||
<li className={wizardStep === 'preview' ? 'active' : ''}>{t('stepPreview')}</li>
|
||||
<li className={wizardStep === 'done' ? 'active' : ''}>{t('stepDone')}</li>
|
||||
</ol>
|
||||
|
||||
{(wizardStep === 'upload' || wizardStep === 'done') && (
|
||||
<div className="wizard-block">
|
||||
<p>{t('uploadHelp')}</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.json,.xlsx,.xls,application/json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(e) => void onFileChosen(e.target.files?.[0] || null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{commitResult && (
|
||||
<p className="influences-success">
|
||||
{t('commitSummary', { inserted: commitResult.inserted, skipped: commitResult.skipped })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'mapping' && parseResult && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('fileInfo', {
|
||||
name: parseResult.filename,
|
||||
rows: parseResult.rowCount,
|
||||
format: parseResult.format,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult.filename,
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{parseResult.sheets && parseResult.sheets.length > 1 && (
|
||||
<label>
|
||||
{t('sheet')}
|
||||
<select
|
||||
value={selectedSheet || ''}
|
||||
onChange={(e) => void reparseSheet(e.target.value)}
|
||||
>
|
||||
{parseResult.sheets.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t('preset')}
|
||||
<select
|
||||
defaultValue={parseResult.suggestedPreset}
|
||||
onChange={(e) => applyPreset(e.target.value)}
|
||||
>
|
||||
{parseResult.presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<table className="influences-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('column')}</th>
|
||||
<th>{t('role')}</th>
|
||||
<th>{t('sample')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parseResult.columns.map((col) => (
|
||||
<tr key={col}>
|
||||
<td>{col}</td>
|
||||
<td>
|
||||
<select
|
||||
value={mapping[col] || 'ignore'}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{parseResult.sampleRows[0]?.[col]?.slice(0, 80) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('upload')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button type="button" disabled={busy || !parseResult.rows} onClick={() => void runPreview()}>
|
||||
{t('runPreview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'preview' && preview && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('previewCounts', {
|
||||
create: preview.counts.willCreate,
|
||||
skip: preview.counts.willSkip,
|
||||
errors: preview.counts.errors,
|
||||
proposals: preview.counts.proposals,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult?.filename || '—',
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
<label className="influences-force">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceImport}
|
||||
onChange={(e) => setForceImport(e.target.checked)}
|
||||
/>
|
||||
{t('forceImport')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{preview.warnings.length > 0 && (
|
||||
<details open={preview.warnings.length < 30}>
|
||||
<summary>
|
||||
{t('warnings', { count: preview.warnings.length })}
|
||||
</summary>
|
||||
<ul className="warnings-list">
|
||||
{preview.warnings.slice(0, 80).map((w, i) => (
|
||||
<li key={`${w.rowIndex}-${i}`}>
|
||||
#{w.rowIndex + 1}: {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colDirection')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{createProposals.map((p: InfluenceImportProposal) => (
|
||||
<tr key={p.edgeKey}>
|
||||
<td>{p.action}</td>
|
||||
<td>
|
||||
{p.artistName}
|
||||
<div className="muted">{p.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{p.sourceLabel}</td>
|
||||
<td>{p.sourceType}</td>
|
||||
<td>{p.direction}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('mapping')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
busy
|
||||
|| preview.counts.willCreate === 0
|
||||
|| (alreadyImported && !forceImport)
|
||||
}
|
||||
onClick={() => void runCommit()}
|
||||
>
|
||||
{t('commitImport', { count: preview.counts.willCreate })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'graph' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={graphArtistQuery}
|
||||
onChange={(e) => setGraphArtistQuery(e.target.value)}
|
||||
placeholder={t('searchArtist')}
|
||||
/>
|
||||
</div>
|
||||
{graphArtistHits.length > 0 && (
|
||||
<ul className="influences-hits">
|
||||
{graphArtistHits.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" onClick={() => void loadGraph(h.id)}>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{graph && (
|
||||
<>
|
||||
{graphArtistId != null && (
|
||||
<p className="muted">Artist id: {graphArtistId}</p>
|
||||
)}
|
||||
<InfluenceGraphSvg graph={graph} onSelectArtist={(id) => {
|
||||
setFilterArtistId(id);
|
||||
setTab('list');
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
{!graph && <p className="muted">{t('graphEmpty')}</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfluenceGraphSvg({
|
||||
graph,
|
||||
onSelectArtist,
|
||||
}: {
|
||||
graph: InfluenceGraph;
|
||||
onSelectArtist: (artistId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('influences');
|
||||
const width = 720;
|
||||
const height = 420;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const focus = graph.nodes.find((n) => n.focus) || graph.nodes[0];
|
||||
const others = graph.nodes.filter((n) => n.id !== focus?.id);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (focus) positions.set(focus.id, { x: cx, y: cy });
|
||||
others.forEach((n, i) => {
|
||||
const angle = (Math.PI * 2 * i) / Math.max(others.length, 1) - Math.PI / 2;
|
||||
const r = 140 + (i % 3) * 28;
|
||||
positions.set(n.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="influences-graph">
|
||||
<h2>{graph.focus.label}</h2>
|
||||
<p className="muted">
|
||||
{t('graphStats', { nodes: graph.nodes.length, edges: graph.edges.length })}
|
||||
</p>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="influences-svg" role="img">
|
||||
{graph.edges.map((e) => {
|
||||
const a = positions.get(e.from);
|
||||
const b = positions.get(e.to);
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${e.id}-${e.from}-${e.to}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className={e.direction === 'influenced_by' ? 'edge-in' : 'edge-out'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => {
|
||||
const p = positions.get(n.id);
|
||||
if (!p) return null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${p.x},${p.y})`}
|
||||
className={`node node-${n.type}${n.focus ? ' focus' : ''}`}
|
||||
onClick={() => {
|
||||
if (n.artistId) onSelectArtist(n.artistId);
|
||||
}}
|
||||
>
|
||||
<circle r={n.focus ? 22 : 14} />
|
||||
<title>{n.label}</title>
|
||||
<text y={n.focus ? 36 : 28} textAnchor="middle">
|
||||
{n.label.length > 28 ? `${n.label.slice(0, 26)}…` : n.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.tours-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.tours-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-back {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tours-error {
|
||||
background: rgba(139, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||
color: #ffaaaa;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tours-list-panel,
|
||||
.tours-editor {
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(15, 15, 26, 0.45);
|
||||
}
|
||||
|
||||
.tours-create {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tours-create input,
|
||||
.tours-meta input,
|
||||
.tours-meta textarea,
|
||||
.tours-meta select,
|
||||
.tours-stops-toolbar input,
|
||||
.tours-stops textarea {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #e8d5b5;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tours-create button,
|
||||
.tours-meta-actions button,
|
||||
.tours-stops-toolbar button,
|
||||
.tours-stop-move button,
|
||||
.tours-hits button,
|
||||
.tours-list button {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tours-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tours-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.tours-list button.active {
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
background: rgba(232, 160, 64, 0.15);
|
||||
}
|
||||
|
||||
.tours-meta {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tours-meta label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-meta-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tours-stops-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tours-hits {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tours-stops {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tours-stop-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tours-stop-move {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ffaaaa !important;
|
||||
border-color: rgba(255, 170, 170, 0.4) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tours-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type TourSummary } from '../api/client';
|
||||
import type { Painting } from '../types';
|
||||
import './ToursPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface StopDraft {
|
||||
paintingId: number;
|
||||
title: string;
|
||||
artistName: string;
|
||||
year: number | null;
|
||||
thumbnailPath: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export default function ToursPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('tours');
|
||||
const [tours, setTours] = useState<TourSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [status, setStatus] = useState<'draft' | 'published'>('draft');
|
||||
const [stops, setStops] = useState<StopDraft[]>([]);
|
||||
const [searchQ, setSearchQ] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<
|
||||
Array<{ id: number; title: string; artistName: string; year: number | null; thumbnailPath: string | null }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listAdminTours();
|
||||
setTours(data.tours);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const openTour = async (id: number) => {
|
||||
setSelectedId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getTour(id);
|
||||
setTitle(data.tour.title);
|
||||
setDescription(data.tour.description || '');
|
||||
setStatus(data.tour.status);
|
||||
setStops(
|
||||
data.paintings.map((p: Painting) => ({
|
||||
paintingId: p.id,
|
||||
title: p.title,
|
||||
artistName: p.artist_name || '',
|
||||
year: p.year ?? null,
|
||||
thumbnailPath: p.thumbnail_path || null,
|
||||
body: data.stopBodies[p.id] || '',
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
if (searchQ.trim().length < 2) {
|
||||
setSearchHits([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.search(searchQ.trim(), { types: 'painting', limit: 12 });
|
||||
setSearchHits(
|
||||
data.results
|
||||
.filter((r) => r.type === 'painting')
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
artistName: r.artist_name,
|
||||
year: r.year,
|
||||
thumbnailPath: r.thumbnail_path,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setSearchHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQ]);
|
||||
|
||||
const createTour = async () => {
|
||||
const name = newTitle.trim();
|
||||
if (!name) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { tour } = await api.createTour({ title: name, status: 'draft' });
|
||||
setNewTitle('');
|
||||
await loadList();
|
||||
await openTour(tour.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateTour(selectedId, {
|
||||
title: title.trim(),
|
||||
description,
|
||||
status,
|
||||
coverPaintingId: stops[0]?.paintingId ?? null,
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveStops = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveTourStops(
|
||||
selectedId,
|
||||
stops.map((s) => ({ paintingId: s.paintingId, body: s.body })),
|
||||
);
|
||||
await api.updateTour(selectedId, {
|
||||
coverPaintingId: stops[0]?.paintingId ?? null,
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTour = async () => {
|
||||
if (!selectedId) return;
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.deleteTour(selectedId);
|
||||
setSelectedId(null);
|
||||
setStops([]);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addStop = (hit: {
|
||||
id: number;
|
||||
title: string;
|
||||
artistName: string;
|
||||
year: number | null;
|
||||
thumbnailPath: string | null;
|
||||
}) => {
|
||||
if (stops.some((s) => s.paintingId === hit.id)) return;
|
||||
setStops((prev) => [
|
||||
...prev,
|
||||
{
|
||||
paintingId: hit.id,
|
||||
title: hit.title,
|
||||
artistName: hit.artistName,
|
||||
year: hit.year,
|
||||
thumbnailPath: hit.thumbnailPath,
|
||||
body: '',
|
||||
},
|
||||
]);
|
||||
setSearchQ('');
|
||||
setSearchHits([]);
|
||||
};
|
||||
|
||||
const moveStop = (index: number, dir: -1 | 1) => {
|
||||
const next = index + dir;
|
||||
if (next < 0 || next >= stops.length) return;
|
||||
setStops((prev) => {
|
||||
const copy = [...prev];
|
||||
const tmp = copy[index];
|
||||
copy[index] = copy[next];
|
||||
copy[next] = tmp;
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tours-page">
|
||||
<header className="tours-header">
|
||||
<button type="button" className="tours-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{error && <div className="tours-error">{error}</div>}
|
||||
|
||||
<div className="tours-layout">
|
||||
<aside className="tours-list-panel">
|
||||
<div className="tours-create">
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t('newTourPlaceholder')}
|
||||
/>
|
||||
<button type="button" disabled={saving || !newTitle.trim()} onClick={() => void createTour()}>
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">{t('loading')}</p>
|
||||
) : (
|
||||
<ul className="tours-list">
|
||||
{tours.map((tour) => (
|
||||
<li key={tour.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedId === tour.id ? 'active' : ''}
|
||||
onClick={() => void openTour(tour.id)}
|
||||
>
|
||||
<strong>{tour.title}</strong>
|
||||
<span className="muted">
|
||||
{tour.status} · {t('stopCount', { count: tour.stopCount })}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{tours.length === 0 && <li className="muted">{t('noTours')}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="tours-editor">
|
||||
{!selectedId ? (
|
||||
<p className="muted">{t('selectTour')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="tours-meta">
|
||||
<label>
|
||||
{t('tourTitle')}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('tourDescription')}
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</label>
|
||||
<label>
|
||||
{t('status')}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
|
||||
>
|
||||
<option value="draft">{t('draft')}</option>
|
||||
<option value="published">{t('published')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="tours-meta-actions">
|
||||
<button type="button" disabled={saving} onClick={() => void saveMeta()}>
|
||||
{t('saveMeta')}
|
||||
</button>
|
||||
<button type="button" className="danger" disabled={saving} onClick={() => void deleteTour()}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tours-stops-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder={t('searchPainting')}
|
||||
/>
|
||||
<button type="button" disabled={saving} onClick={() => void saveStops()}>
|
||||
{t('saveStops')}
|
||||
</button>
|
||||
</div>
|
||||
{searchHits.length > 0 && (
|
||||
<ul className="tours-hits">
|
||||
{searchHits.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" onClick={() => addStop(h)}>
|
||||
{h.artistName} — {h.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ol className="tours-stops">
|
||||
{stops.map((stop, index) => (
|
||||
<li key={stop.paintingId}>
|
||||
<div className="tours-stop-head">
|
||||
<span>
|
||||
{index + 1}. {stop.artistName} — {stop.title}
|
||||
{stop.year != null ? ` (${stop.year})` : ''}
|
||||
</span>
|
||||
<div className="tours-stop-move">
|
||||
<button type="button" onClick={() => moveStop(index, -1)} disabled={index === 0}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStop(index, 1)}
|
||||
disabled={index === stops.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => setStops((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={stop.body}
|
||||
onChange={(e) =>
|
||||
setStops((prev) =>
|
||||
prev.map((s, i) => (i === index ? { ...s, body: e.target.value } : s)),
|
||||
)
|
||||
}
|
||||
rows={4}
|
||||
placeholder={t('stopBodyPlaceholder')}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{stops.length === 0 && <li className="muted">{t('noStops')}</li>}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.translations-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.translations-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-coverage {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.translations-toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-toolbar select {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.translations-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.translations-list {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.translations-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.translations-list th,
|
||||
.translations-list td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.translations-row-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-editor {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.translations-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-field textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.translations-canonical {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.translations-error {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.translations-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type TranslationDetail, type TranslationWorklistItem } from '../api/client';
|
||||
import './TranslationsPage.css';
|
||||
|
||||
type EntityType = 'artist' | 'painting' | 'movement';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function TranslationsPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('translations');
|
||||
const [entityType, setEntityType] = useState<EntityType>('artist');
|
||||
const [items, setItems] = useState<TranslationWorklistItem[]>([]);
|
||||
const [coverage, setCoverage] = useState<Record<string, number> | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<TranslationDetail | null>(null);
|
||||
const [draftFields, setDraftFields] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cov, list] = await Promise.all([
|
||||
api.getTranslationCoverage('ru'),
|
||||
api.getTranslationWorklist(entityType, 'ru'),
|
||||
]);
|
||||
setCoverage(cov.coverage);
|
||||
setItems(list.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
}, [loadList]);
|
||||
|
||||
const openItem = async (entityId: number) => {
|
||||
setSelectedId(entityId);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getEntityTranslation(entityType, entityId);
|
||||
setDetail(data);
|
||||
const ruFields: Record<string, string> = {};
|
||||
for (const field of data.translatableFields) {
|
||||
const row = data.translations.find((tr) => tr.locale === 'ru' && tr.field_name === field);
|
||||
ruFields[field] = row?.value ?? '';
|
||||
}
|
||||
setDraftFields(ruFields);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveEntityTranslation(entityType, selectedId, {
|
||||
locale: 'ru',
|
||||
fields: draftFields,
|
||||
status: 'draft',
|
||||
});
|
||||
await api.publishEntityTranslation(entityType, selectedId, 'ru');
|
||||
await loadList();
|
||||
await openItem(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="translations-page">
|
||||
<header className="translations-header">
|
||||
<button type="button" className="translations-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{coverage && (
|
||||
<div className="translations-coverage">
|
||||
<strong>{t('coverage')}:</strong>{' '}
|
||||
{t('artistsBio')}: {coverage.artists_bio_full}/{coverage.artists_total} ·{' '}
|
||||
{t('paintingsTitle')}: {coverage.paintings_title}/{coverage.paintings_total} ·{' '}
|
||||
{t('published')}: {coverage.published_count} · {t('draft')}: {coverage.draft_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="translations-toolbar">
|
||||
<label>
|
||||
{t('entityType')}
|
||||
<select value={entityType} onChange={(e) => setEntityType(e.target.value as EntityType)}>
|
||||
<option value="artist">artist</option>
|
||||
<option value="painting">painting</option>
|
||||
<option value="movement">movement</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="translations-error">{error}</p>}
|
||||
{loading && <p>{t('loadFailed')}…</p>}
|
||||
|
||||
<div className="translations-layout">
|
||||
<div className="translations-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{t('canonical')}</th>
|
||||
<th>{t('status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.entityId}
|
||||
className={selectedId === item.entityId ? 'translations-row-selected' : ''}
|
||||
onClick={() => void openItem(item.entityId)}
|
||||
>
|
||||
<td>{item.entityId}</td>
|
||||
<td>{item.label}</td>
|
||||
<td>
|
||||
{item.publishedCount} / {item.draftCount} draft · {item.missingFields.length} missing
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && items.length === 0 && <p>{t('noRows')}</p>}
|
||||
</div>
|
||||
|
||||
{detail && selectedId && (
|
||||
<div className="translations-editor">
|
||||
<h2>{String(detail.canonical.name || detail.canonical.title || selectedId)}</h2>
|
||||
{detail.translatableFields.map((field) => (
|
||||
<div key={field} className="translations-field">
|
||||
<label>{field}</label>
|
||||
<p className="translations-canonical">
|
||||
<strong>{t('canonical')}:</strong>{' '}
|
||||
{String(detail.canonical[field] ?? '')}
|
||||
</p>
|
||||
<textarea
|
||||
rows={field.includes('bio') || field === 'body' ? 8 : 3}
|
||||
value={draftFields[field] ?? ''}
|
||||
onChange={(e) => setDraftFields((prev) => ({ ...prev, [field]: e.target.value }))}
|
||||
placeholder={t('translation')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={saving} onClick={() => void saveDraft()}>
|
||||
{saving ? '…' : t('publish')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,11 @@ export interface Artist {
|
||||
movement_name?: string;
|
||||
movement_color?: string;
|
||||
portrait_path: string | null;
|
||||
bio_short: string;
|
||||
bio_full: string;
|
||||
portrait_thumb_path?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
bio_short?: string;
|
||||
bio_full?: string;
|
||||
wikipedia_title: string;
|
||||
century: number;
|
||||
checkup_checked?: boolean;
|
||||
@@ -59,6 +62,8 @@ export interface Painting {
|
||||
description: string;
|
||||
image_path: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
wikipedia_title: string;
|
||||
sort_order: number;
|
||||
artist_name?: string;
|
||||
@@ -128,11 +133,78 @@ export interface MovementGalleryDetail {
|
||||
paintings: Painting[];
|
||||
}
|
||||
|
||||
export interface TourSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'draft' | 'published';
|
||||
coverPaintingId: number | null;
|
||||
coverThumbnailPath: string | null;
|
||||
coverImagePath: string | null;
|
||||
stopCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TourGalleryDetail {
|
||||
tour: TourSummary;
|
||||
paintings: Painting[];
|
||||
stopBodies: Record<number, string>;
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
eras: HistoricalEra[];
|
||||
movements: ArtMovement[];
|
||||
}
|
||||
|
||||
export interface CatalogBootstrap extends TimelineData {
|
||||
bounds: YearBounds;
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export interface CatalogSearchArtistResult {
|
||||
type: 'artist';
|
||||
id: number;
|
||||
name: string;
|
||||
birth_year: number | null;
|
||||
death_year: number | null;
|
||||
movement_name: string | null;
|
||||
portrait_path: string | null;
|
||||
portrait_thumb_path: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchMovementResult {
|
||||
type: 'movement';
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
era_name: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchPaintingResult {
|
||||
type: 'painting';
|
||||
id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
artist_id: number;
|
||||
artist_name: string;
|
||||
movement_name: string | null;
|
||||
thumbnail_path: string | null;
|
||||
image_path: string | null;
|
||||
}
|
||||
|
||||
export type CatalogSearchResult =
|
||||
| CatalogSearchArtistResult
|
||||
| CatalogSearchMovementResult
|
||||
| CatalogSearchPaintingResult;
|
||||
|
||||
export interface CatalogSearchResponse {
|
||||
q: string;
|
||||
results: CatalogSearchResult[];
|
||||
}
|
||||
|
||||
export interface YearBounds {
|
||||
min_year: number;
|
||||
max_year: number;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const LOCALE_STORAGE_KEY = 'gallery_locale';
|
||||
export const SUPPORTED_LOCALES = ['en', 'ru'] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export function readStoredLocale(): AppLocale {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored === 'ru' || stored === 'en') return stored;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const nav = typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||
return nav.toLowerCase().startsWith('ru') ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
export function writeStoredLocale(locale: AppLocale) {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
export function localeQuery(locale: AppLocale): string {
|
||||
return locale === 'en' ? '' : `locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
|
||||
export function withLocale(url: string, locale: AppLocale): string {
|
||||
if (locale === 'en') return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Painting } from '../types';
|
||||
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import { comparePaintingsChronological } from './paintingUtils';
|
||||
|
||||
/** Target capacity per movement wing (50–60 works). */
|
||||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||||
@@ -92,10 +91,6 @@ function layoutRow(paintings: Painting[], span: number) {
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||
}
|
||||
|
||||
function orderForWall(paintings: Painting[]) {
|
||||
return [...paintings].sort(comparePaintingsChronological).reverse();
|
||||
}
|
||||
|
||||
function formatYearLabel(paintings: Painting[]) {
|
||||
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
|
||||
if (years.length === 0) return 'Undated works';
|
||||
@@ -104,22 +99,27 @@ function formatYearLabel(paintings: Painting[]) {
|
||||
return min === max ? `${min}` : `${min} – ${max}`;
|
||||
}
|
||||
|
||||
/** Preserve caller order (chrono for movements, stop order for tours). */
|
||||
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
if (sorted.length === 0) return [[]];
|
||||
if (paintings.length === 0) return [[]];
|
||||
const chunks: Painting[][] = [];
|
||||
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||||
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||||
for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||||
chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* First half → left wall, second half → right.
|
||||
* Callers pass paintings already in visit order; first work hangs near the
|
||||
* entrance on the left, last work near the entrance on the right.
|
||||
*/
|
||||
function distributeToSideWalls(paintings: Painting[]) {
|
||||
const left: Painting[] = [];
|
||||
const right: Painting[] = [];
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
|
||||
return { left: orderForWall(left), right: orderForWall(right) };
|
||||
const mid = Math.ceil(paintings.length / 2);
|
||||
return {
|
||||
left: paintings.slice(0, mid),
|
||||
right: paintings.slice(mid),
|
||||
};
|
||||
}
|
||||
|
||||
function layoutSideSlots(
|
||||
@@ -132,16 +132,21 @@ function layoutSideSlots(
|
||||
if (paintings.length === 0) return [];
|
||||
const { slots: rowSlots } = layoutRow(paintings, span);
|
||||
const y = EYE_HEIGHT;
|
||||
return rowSlots.map((s) => ({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
side,
|
||||
position:
|
||||
side === 'left'
|
||||
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
|
||||
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
|
||||
}));
|
||||
return rowSlots.map((s) => {
|
||||
// layoutRow places index 0 at negative offset. Flip on the left wall so
|
||||
// the first painting sits at the entrance (+Z), left of the starting view.
|
||||
const alongWall = side === 'left' ? -s.offset : s.offset;
|
||||
return {
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
side,
|
||||
position:
|
||||
side === 'left'
|
||||
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
|
||||
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMovementHallLayout(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
const MAX_CONCURRENT = 8;
|
||||
const queue: Array<() => void> = [];
|
||||
let inFlight = 0;
|
||||
|
||||
function pumpQueue() {
|
||||
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
|
||||
export function loadTextureQueued(url: string): Promise<THREE.Texture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = () => {
|
||||
inFlight++;
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
resolve(tex);
|
||||
},
|
||||
undefined,
|
||||
(err) => {
|
||||
inFlight--;
|
||||
pumpQueue();
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
queue.push(task);
|
||||
pumpQueue();
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,45 @@
|
||||
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
|
||||
|
||||
/** "Nice" year steps for axis labels (ascending). */
|
||||
const TICK_INTERVALS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000];
|
||||
|
||||
/** Pick a label interval from span + pixel width so dates do not overlap. */
|
||||
export function chooseTimelineTickInterval(
|
||||
spanYears: number,
|
||||
widthPx: number,
|
||||
minLabelGapPx = 76
|
||||
): number {
|
||||
if (spanYears <= 0) return 1;
|
||||
const width = Math.max(widthPx, 320);
|
||||
const minYears = (spanYears / width) * minLabelGapPx;
|
||||
|
||||
let chosen = TICK_INTERVALS[TICK_INTERVALS.length - 1];
|
||||
for (const interval of TICK_INTERVALS) {
|
||||
if (interval >= minYears) {
|
||||
chosen = interval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap — never more than ~16 labelled ticks
|
||||
while (spanYears / chosen > 16) {
|
||||
const idx = TICK_INTERVALS.indexOf(chosen);
|
||||
if (idx < 0 || idx >= TICK_INTERVALS.length - 1) break;
|
||||
chosen = TICK_INTERVALS[idx + 1];
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function buildTimelineTickYears(viewStart: number, viewEnd: number, interval: number): number[] {
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / interval) * interval;
|
||||
for (let y = firstTick; y <= viewEnd; y += interval) {
|
||||
ticks.push(y);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function zoomTimelineView(
|
||||
clientX: number,
|
||||
rectLeft: number,
|
||||
@@ -49,3 +90,38 @@ export function panTimelineView(
|
||||
}
|
||||
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||
}
|
||||
|
||||
/** Coalesce rapid view updates to one React commit per animation frame. */
|
||||
export function createViewChangeScheduler(onApply: (start: number, end: number) => void) {
|
||||
let rafId: number | null = null;
|
||||
let pending: { start: number; end: number } | null = null;
|
||||
|
||||
return {
|
||||
schedule(start: number, end: number) {
|
||||
pending = { start, end };
|
||||
if (rafId != null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const next = pending;
|
||||
pending = null;
|
||||
if (next) onApply(next.start, next.end);
|
||||
});
|
||||
},
|
||||
flush() {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pending) {
|
||||
const next = pending;
|
||||
pending = null;
|
||||
onApply(next.start, next.end);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['devgallery.mysuperlab.netcraze.pro', 'gallery.mysuperlab.netcraze.pro', 'localhost'],
|
||||
proxy: {
|
||||
'/api': apiTarget,
|
||||
'/images': apiTarget,
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 14 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 830 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 257 KiB |