diff --git a/Documentation/API.md b/Documentation/API.md index 994c9b8..8c9f910 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -86,6 +86,48 @@ Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_ --- +## `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` Returns the overall timeline year range used to initialise the zoomable timeline. @@ -138,11 +180,11 @@ 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_full` and other heavy fields) | +| `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/bounds`, `GET /api/timeline?start=…&end=…` (full range), and `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). +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). --- diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 6e40cde..bdc9da0 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -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/ @@ -47,7 +49,8 @@ Promote dev → prod files: `npm run images:sync-to-prod` (after `net use \\192. | `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk | | `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API | | `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` | -| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` | +| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` | +| `regenerate-portrait-thumbs.js` | `npm run regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` | | `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 | @@ -154,6 +157,8 @@ Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk fil ## 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 regenerate-portrait-thumbs` to backfill thumbs on dev. + `npm run 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. diff --git a/client/src/api/client.ts b/client/src/api/client.ts index eb6a720..d8e31fd 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,5 +1,6 @@ import type { TimelineData, + CatalogBootstrap, YearBounds, Artist, ArtistDetail, @@ -62,13 +63,25 @@ export function portraitUrl(path: string | null | undefined, revision?: number): return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; } -/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */ +/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */ +export function portraitThumbUrl( + artist: { + portrait_thumb_path?: string | null; + portrait_path?: string | null; + }, + revision?: number +): string { + const path = artist.portrait_thumb_path || artist.portrait_path; + return portraitUrl(path, revision); +} + +/** Image for 3D gallery — prefer thumbnail for faster texture loads */ export function galleryImageUrl(painting: { thumbnail_path?: string | null; image_path?: string | null; }): string | null { - if (painting.image_path) return `/images/${painting.image_path}`; if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; + if (painting.image_path) return `/images/${painting.image_path}`; return null; } @@ -202,6 +215,14 @@ export interface PaintingCheckupData { export const api = { getBounds: () => fetchJson(`${API}/bounds`), + getCatalogBootstrap: (start?: number, end?: number) => { + const params = new URLSearchParams(); + if (start != null) params.set('start', String(start)); + if (end != null) params.set('end', String(end)); + const qs = params.toString(); + return fetchJson(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`); + }, + getTimeline: (start: number, end: number) => fetchJson(`${API}/timeline?start=${start}&end=${end}`), @@ -345,6 +366,8 @@ export const api = { } return res.json() as Promise<{ checked: boolean; fixed: boolean }>; }), + + preloadArtistImages, }; export function debugImageProxyUrl( diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 37d57e8..b90ee21 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -1,6 +1,7 @@ -import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react'; import type { ArtMovement, Artist } from '../types'; -import { portraitUrl } from '../api/client'; +import { portraitThumbUrl } from '../api/client'; +import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import './MovementBands.css'; @@ -639,6 +640,108 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } { return { x, y: yOnStream(layout, x) }; } +const MovementArtistPortrait = memo(function MovementArtistPortrait({ + artist, + portraitRevision, + lineLeft, + lineWidth, + portraitX, + y, + layoutHeight, + color, + colorIndex, + isHovered, + birthLabel, + deathLabel, + movementName, + viewStart, + viewEnd, + onArtistClick, + onArtistHover, + onHoverStart, + onHoverEnd, +}: { + artist: Artist; + portraitRevision?: number; + lineLeft: number; + lineWidth: number; + portraitX: number; + y: number; + layoutHeight: number; + color: string; + colorIndex: number; + isHovered: boolean; + birthLabel: string | number; + deathLabel: string | number; + movementName: string; + viewStart: number; + viewEnd: number; + onArtistClick: (id: number) => void; + onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void; + onHoverStart: () => void; + onHoverEnd: () => void; +}) { + const thumbSrc = portraitThumbUrl(artist, portraitRevision); + const queuedSrc = useQueuedImageSrc(thumbSrc); + + return ( +
+
+ {isHovered && } +
+ +
+ ); +}); + export default function MovementBands({ movements, artists, @@ -674,7 +777,7 @@ export default function MovementBands({ interactionTimer.current = window.setTimeout(() => { interactionTimer.current = null; setInteracting(false); - }, 120); + }, 200); }, []); useEffect(() => () => { @@ -1216,60 +1319,30 @@ export default function MovementBands({ const birthLabel = artist.birth_year != null ? artist.birth_year : '?'; const deathLabel = artist.death_year != null ? artist.death_year : '?'; const artistKey = `${layout.movement.id}-${artist.id}`; - const isHovered = hoveredArtistKey === artistKey; return ( -
-
- {isHovered && } -
- -
+ artist={artist} + portraitRevision={portraitRevisions?.[artist.id]} + lineLeft={lineLeft} + lineWidth={lineWidth} + portraitX={portraitX} + y={y} + layoutHeight={layoutHeight} + color={color} + colorIndex={colorIndex} + isHovered={hoveredArtistKey === artistKey} + birthLabel={birthLabel} + deathLabel={deathLabel} + movementName={layout.movement.name} + viewStart={viewStart} + viewEnd={viewEnd} + onArtistClick={onArtistClick} + onArtistHover={onArtistHover} + onHoverStart={() => setHoveredArtistKey(artistKey)} + onHoverEnd={() => setHoveredArtistKey(null)} + /> ); })} diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 808ddd0..c5487c6 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -130,6 +130,8 @@ function InfluenceCard({ {artistName} { (e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; }} @@ -164,6 +166,8 @@ function InfluenceCard({ {inf.title { (e.target as HTMLImageElement).src = '/placeholder-art.svg'; }} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index e703e8e..cd6f024 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -10,7 +10,8 @@ import type { ArtistNavigation, MovementArtistGroup, } from '../types'; -import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client'; +import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client'; +import { loadTextureQueued } from '../utils/textureLoadQueue'; import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; @@ -561,11 +562,9 @@ function usePaintingTexture(url: string | null) { setFailed(false); let disposed = false; let loaded: THREE.Texture | null = null; - const loader = new THREE.TextureLoader(); - loader.setCrossOrigin('anonymous'); - loader.load( - url, - (tex) => { + + loadTextureQueued(url) + .then((tex) => { if (disposed) { tex.dispose(); return; @@ -580,12 +579,10 @@ function usePaintingTexture(url: string | null) { tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; setTexture(tex); - }, - undefined, - () => { + }) + .catch(() => { if (!disposed) setFailed(true); - } - ); + }); return () => { disposed = true; @@ -1675,32 +1672,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((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), @@ -2017,7 +1995,7 @@ 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]} /> 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((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(undefined); + + useEffect(() => { + if (!src) { + setActiveSrc(undefined); + return; + } + + let cancelled = false; + setActiveSrc(undefined); + + enqueueLoad(() => { + if (!cancelled) setActiveSrc(src); + }); + + return () => { + cancelled = true; + }; + }, [src]); + + return activeSrc; +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 63c7b88..fb2d21d 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; import Timeline from '../components/Timeline'; import TimelineEventGuides from '../components/TimelineEventGuides'; import MovementBands from '../components/MovementBands'; -import VirtualGallery from '../components/VirtualGallery'; +const VirtualGallery = lazy(() => import('../components/VirtualGallery')); import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; @@ -144,23 +144,16 @@ export default function HomePage() { (async () => { try { setLoading(true); - const b = await api.getBounds(); - const min = b.min_year ?? -800; - const max = b.max_year ?? 2025; + 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); - - const [timeline, artistList] = await Promise.all([ - api.getTimeline(min, max), - api.getTimelineArtists(), - ]); - if (cancelled) return; - - setTimelineData(timeline); - setArtists(artistList); + 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?'); @@ -407,6 +400,7 @@ export default function HomePage() { const handleArtistClick = async (artistId: number) => { try { + await api.preloadArtistImages(artistId).catch(() => undefined); const data = await api.getArtist(artistId); openArtistGallery(artistId, data); } catch { @@ -605,6 +599,7 @@ export default function HomePage() { aria-hidden={!galleryActive} > {displayGallery.kind === 'artist' ? ( + + ) : ( + setView({ type: 'timeline' })} /> + )} )} diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 7f69b88..308b827 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -31,8 +31,9 @@ export interface Artist { movement_name?: string; movement_color?: string; portrait_path: string | null; - bio_short: string; - bio_full: string; + portrait_thumb_path?: string | null; + bio_short?: string; + bio_full?: string; wikipedia_title: string; century: number; checkup_checked?: boolean; @@ -133,6 +134,11 @@ export interface TimelineData { movements: ArtMovement[]; } +export interface CatalogBootstrap extends TimelineData { + bounds: YearBounds; + artists: Artist[]; +} + export interface YearBounds { min_year: number; max_year: number; diff --git a/client/src/utils/textureLoadQueue.ts b/client/src/utils/textureLoadQueue.ts new file mode 100644 index 0000000..a38b39a --- /dev/null +++ b/client/src/utils/textureLoadQueue.ts @@ -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 { + 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(); + }); +} diff --git a/data/images/portraits/thumbs/Albrecht_D_rer_thumb.jpg b/data/images/portraits/thumbs/Albrecht_D_rer_thumb.jpg new file mode 100644 index 0000000..1f507f0 Binary files /dev/null and b/data/images/portraits/thumbs/Albrecht_D_rer_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Alexander_Rodchenko_thumb.jpg b/data/images/portraits/thumbs/Alexander_Rodchenko_thumb.jpg new file mode 100644 index 0000000..db7d31e Binary files /dev/null and b/data/images/portraits/thumbs/Alexander_Rodchenko_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Alphonse_Mucha_thumb.jpg b/data/images/portraits/thumbs/Alphonse_Mucha_thumb.jpg new file mode 100644 index 0000000..de35d70 Binary files /dev/null and b/data/images/portraits/thumbs/Alphonse_Mucha_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Andr__Derain_thumb.jpg b/data/images/portraits/thumbs/Andr__Derain_thumb.jpg new file mode 100644 index 0000000..118ca6d Binary files /dev/null and b/data/images/portraits/thumbs/Andr__Derain_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Andrea_del_Castagno_thumb.jpg b/data/images/portraits/thumbs/Andrea_del_Castagno_thumb.jpg new file mode 100644 index 0000000..374aa05 Binary files /dev/null and b/data/images/portraits/thumbs/Andrea_del_Castagno_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Andrei_Rublev_thumb.jpg b/data/images/portraits/thumbs/Andrei_Rublev_thumb.jpg new file mode 100644 index 0000000..bc0df08 Binary files /dev/null and b/data/images/portraits/thumbs/Andrei_Rublev_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Andy_Warhol_thumb.jpg b/data/images/portraits/thumbs/Andy_Warhol_thumb.jpg new file mode 100644 index 0000000..558940e Binary files /dev/null and b/data/images/portraits/thumbs/Andy_Warhol_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Angelica_Kauffman_thumb.jpg b/data/images/portraits/thumbs/Angelica_Kauffman_thumb.jpg new file mode 100644 index 0000000..7daa93a Binary files /dev/null and b/data/images/portraits/thumbs/Angelica_Kauffman_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Apelles_thumb.jpg b/data/images/portraits/thumbs/Apelles_thumb.jpg new file mode 100644 index 0000000..4e81606 Binary files /dev/null and b/data/images/portraits/thumbs/Apelles_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Arnold_B_cklin_thumb.jpg b/data/images/portraits/thumbs/Arnold_B_cklin_thumb.jpg new file mode 100644 index 0000000..d10f7d8 Binary files /dev/null and b/data/images/portraits/thumbs/Arnold_B_cklin_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Artemisia_Gentileschi_thumb.jpg b/data/images/portraits/thumbs/Artemisia_Gentileschi_thumb.jpg new file mode 100644 index 0000000..fcdc9f3 Binary files /dev/null and b/data/images/portraits/thumbs/Artemisia_Gentileschi_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Aubrey_Beardsley_thumb.jpg b/data/images/portraits/thumbs/Aubrey_Beardsley_thumb.jpg new file mode 100644 index 0000000..994d4a5 Binary files /dev/null and b/data/images/portraits/thumbs/Aubrey_Beardsley_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Bronzino_thumb.jpg b/data/images/portraits/thumbs/Bronzino_thumb.jpg new file mode 100644 index 0000000..7f8006e Binary files /dev/null and b/data/images/portraits/thumbs/Bronzino_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Camille_Pissarro_thumb.jpg b/data/images/portraits/thumbs/Camille_Pissarro_thumb.jpg new file mode 100644 index 0000000..3e89759 Binary files /dev/null and b/data/images/portraits/thumbs/Camille_Pissarro_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Caravaggio_thumb.jpg b/data/images/portraits/thumbs/Caravaggio_thumb.jpg new file mode 100644 index 0000000..d94e61a Binary files /dev/null and b/data/images/portraits/thumbs/Caravaggio_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Caspar_David_Friedrich_thumb.jpg b/data/images/portraits/thumbs/Caspar_David_Friedrich_thumb.jpg new file mode 100644 index 0000000..080b053 Binary files /dev/null and b/data/images/portraits/thumbs/Caspar_David_Friedrich_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Cimabue_thumb.jpg b/data/images/portraits/thumbs/Cimabue_thumb.jpg new file mode 100644 index 0000000..9b18304 Binary files /dev/null and b/data/images/portraits/thumbs/Cimabue_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Claude_Lorrain_thumb.jpg b/data/images/portraits/thumbs/Claude_Lorrain_thumb.jpg new file mode 100644 index 0000000..0a64252 Binary files /dev/null and b/data/images/portraits/thumbs/Claude_Lorrain_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Claude_Monet_thumb.jpg b/data/images/portraits/thumbs/Claude_Monet_thumb.jpg new file mode 100644 index 0000000..456264c Binary files /dev/null and b/data/images/portraits/thumbs/Claude_Monet_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Diego_Vel_zquez_thumb.jpg b/data/images/portraits/thumbs/Diego_Vel_zquez_thumb.jpg new file mode 100644 index 0000000..3ee855d Binary files /dev/null and b/data/images/portraits/thumbs/Diego_Vel_zquez_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Domenico_Ghirlandaio_thumb.jpg b/data/images/portraits/thumbs/Domenico_Ghirlandaio_thumb.jpg new file mode 100644 index 0000000..c0a8923 Binary files /dev/null and b/data/images/portraits/thumbs/Domenico_Ghirlandaio_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Duccio_thumb.jpg b/data/images/portraits/thumbs/Duccio_thumb.jpg new file mode 100644 index 0000000..be1974f Binary files /dev/null and b/data/images/portraits/thumbs/Duccio_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Edgar_Degas_thumb.jpg b/data/images/portraits/thumbs/Edgar_Degas_thumb.jpg new file mode 100644 index 0000000..7b71a42 Binary files /dev/null and b/data/images/portraits/thumbs/Edgar_Degas_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Edvard_Munch_thumb.jpg b/data/images/portraits/thumbs/Edvard_Munch_thumb.jpg new file mode 100644 index 0000000..8cd63ff Binary files /dev/null and b/data/images/portraits/thumbs/Edvard_Munch_thumb.jpg differ diff --git a/data/images/portraits/thumbs/El_Greco_thumb.jpg b/data/images/portraits/thumbs/El_Greco_thumb.jpg new file mode 100644 index 0000000..fa9b62e Binary files /dev/null and b/data/images/portraits/thumbs/El_Greco_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Ernst_Ludwig_Kirchner_thumb.jpg b/data/images/portraits/thumbs/Ernst_Ludwig_Kirchner_thumb.jpg new file mode 100644 index 0000000..bc25878 Binary files /dev/null and b/data/images/portraits/thumbs/Ernst_Ludwig_Kirchner_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Eug_ne_Delacroix_thumb.jpg b/data/images/portraits/thumbs/Eug_ne_Delacroix_thumb.jpg new file mode 100644 index 0000000..21ff013 Binary files /dev/null and b/data/images/portraits/thumbs/Eug_ne_Delacroix_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Fra_Angelico_thumb.jpg b/data/images/portraits/thumbs/Fra_Angelico_thumb.jpg new file mode 100644 index 0000000..338f173 Binary files /dev/null and b/data/images/portraits/thumbs/Fra_Angelico_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Fran_ois_Boucher_thumb.jpg b/data/images/portraits/thumbs/Fran_ois_Boucher_thumb.jpg new file mode 100644 index 0000000..77cdb45 Binary files /dev/null and b/data/images/portraits/thumbs/Fran_ois_Boucher_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Francis_Picabia_thumb.jpg b/data/images/portraits/thumbs/Francis_Picabia_thumb.jpg new file mode 100644 index 0000000..5ac58cb Binary files /dev/null and b/data/images/portraits/thumbs/Francis_Picabia_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Francisco_Goya_thumb.jpg b/data/images/portraits/thumbs/Francisco_Goya_thumb.jpg new file mode 100644 index 0000000..f0dc357 Binary files /dev/null and b/data/images/portraits/thumbs/Francisco_Goya_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Georges_Braque_thumb.jpg b/data/images/portraits/thumbs/Georges_Braque_thumb.jpg new file mode 100644 index 0000000..62d9374 Binary files /dev/null and b/data/images/portraits/thumbs/Georges_Braque_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Georges_Seurat_thumb.jpg b/data/images/portraits/thumbs/Georges_Seurat_thumb.jpg new file mode 100644 index 0000000..d4f0359 Binary files /dev/null and b/data/images/portraits/thumbs/Georges_Seurat_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Giacomo_Balla_thumb.jpg b/data/images/portraits/thumbs/Giacomo_Balla_thumb.jpg new file mode 100644 index 0000000..84bcbf2 Binary files /dev/null and b/data/images/portraits/thumbs/Giacomo_Balla_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Gino_Severini_thumb.jpg b/data/images/portraits/thumbs/Gino_Severini_thumb.jpg new file mode 100644 index 0000000..8659b65 Binary files /dev/null and b/data/images/portraits/thumbs/Gino_Severini_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Giorgio_de_Chirico_thumb.jpg b/data/images/portraits/thumbs/Giorgio_de_Chirico_thumb.jpg new file mode 100644 index 0000000..6a1ade5 Binary files /dev/null and b/data/images/portraits/thumbs/Giorgio_de_Chirico_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Giorgione_thumb.jpg b/data/images/portraits/thumbs/Giorgione_thumb.jpg new file mode 100644 index 0000000..dbe8698 Binary files /dev/null and b/data/images/portraits/thumbs/Giorgione_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Giotto_thumb.jpg b/data/images/portraits/thumbs/Giotto_thumb.jpg new file mode 100644 index 0000000..dcb466b Binary files /dev/null and b/data/images/portraits/thumbs/Giotto_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Gustav_Klimt_thumb.jpg b/data/images/portraits/thumbs/Gustav_Klimt_thumb.jpg new file mode 100644 index 0000000..7a4bf1c Binary files /dev/null and b/data/images/portraits/thumbs/Gustav_Klimt_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Gustave_Courbet_thumb.jpg b/data/images/portraits/thumbs/Gustave_Courbet_thumb.jpg new file mode 100644 index 0000000..267b5e9 Binary files /dev/null and b/data/images/portraits/thumbs/Gustave_Courbet_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Hannah_H_ch_thumb.jpg b/data/images/portraits/thumbs/Hannah_H_ch_thumb.jpg new file mode 100644 index 0000000..ef3c789 Binary files /dev/null and b/data/images/portraits/thumbs/Hannah_H_ch_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Henri_Matisse_thumb.jpg b/data/images/portraits/thumbs/Henri_Matisse_thumb.jpg new file mode 100644 index 0000000..068ec81 Binary files /dev/null and b/data/images/portraits/thumbs/Henri_Matisse_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Henri_Privat-Livemont_thumb.jpg b/data/images/portraits/thumbs/Henri_Privat-Livemont_thumb.jpg new file mode 100644 index 0000000..9e03e4e Binary files /dev/null and b/data/images/portraits/thumbs/Henri_Privat-Livemont_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Henri_de_Toulouse-Lautrec_thumb.jpg b/data/images/portraits/thumbs/Henri_de_Toulouse-Lautrec_thumb.jpg new file mode 100644 index 0000000..8c28a69 Binary files /dev/null and b/data/images/portraits/thumbs/Henri_de_Toulouse-Lautrec_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Hieronymus_Bosch_thumb.jpg b/data/images/portraits/thumbs/Hieronymus_Bosch_thumb.jpg new file mode 100644 index 0000000..32dd8bc Binary files /dev/null and b/data/images/portraits/thumbs/Hieronymus_Bosch_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Honor__Daumier_thumb.jpg b/data/images/portraits/thumbs/Honor__Daumier_thumb.jpg new file mode 100644 index 0000000..19ed9b9 Binary files /dev/null and b/data/images/portraits/thumbs/Honor__Daumier_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Ivan_Klyun_thumb.jpg b/data/images/portraits/thumbs/Ivan_Klyun_thumb.jpg new file mode 100644 index 0000000..d55b3ed Binary files /dev/null and b/data/images/portraits/thumbs/Ivan_Klyun_thumb.jpg differ diff --git a/data/images/portraits/thumbs/J__M__W__Turner_thumb.jpg b/data/images/portraits/thumbs/J__M__W__Turner_thumb.jpg new file mode 100644 index 0000000..24faa4a Binary files /dev/null and b/data/images/portraits/thumbs/J__M__W__Turner_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jackson_Pollock_thumb.jpg b/data/images/portraits/thumbs/Jackson_Pollock_thumb.jpg new file mode 100644 index 0000000..41d9e39 Binary files /dev/null and b/data/images/portraits/thumbs/Jackson_Pollock_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jacques-Louis_David_thumb.jpg b/data/images/portraits/thumbs/Jacques-Louis_David_thumb.jpg new file mode 100644 index 0000000..f42fdd3 Binary files /dev/null and b/data/images/portraits/thumbs/Jacques-Louis_David_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jan_van_Eyck_thumb.jpg b/data/images/portraits/thumbs/Jan_van_Eyck_thumb.jpg new file mode 100644 index 0000000..5b5c963 Binary files /dev/null and b/data/images/portraits/thumbs/Jan_van_Eyck_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jean-Antoine_Watteau_thumb.jpg b/data/images/portraits/thumbs/Jean-Antoine_Watteau_thumb.jpg new file mode 100644 index 0000000..a1577ee Binary files /dev/null and b/data/images/portraits/thumbs/Jean-Antoine_Watteau_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jean-Auguste-Dominique_Ingres_thumb.jpg b/data/images/portraits/thumbs/Jean-Auguste-Dominique_Ingres_thumb.jpg new file mode 100644 index 0000000..d41d687 Binary files /dev/null and b/data/images/portraits/thumbs/Jean-Auguste-Dominique_Ingres_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jean-Baptiste-Camille_Corot_thumb.jpg b/data/images/portraits/thumbs/Jean-Baptiste-Camille_Corot_thumb.jpg new file mode 100644 index 0000000..e053590 Binary files /dev/null and b/data/images/portraits/thumbs/Jean-Baptiste-Camille_Corot_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jean-Fran_ois_Millet_thumb.jpg b/data/images/portraits/thumbs/Jean-Fran_ois_Millet_thumb.jpg new file mode 100644 index 0000000..1e3f8e4 Binary files /dev/null and b/data/images/portraits/thumbs/Jean-Fran_ois_Millet_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Jean-Honor__Fragonard_thumb.jpg b/data/images/portraits/thumbs/Jean-Honor__Fragonard_thumb.jpg new file mode 100644 index 0000000..8038d9e Binary files /dev/null and b/data/images/portraits/thumbs/Jean-Honor__Fragonard_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Joan_Mir__thumb.jpg b/data/images/portraits/thumbs/Joan_Mir__thumb.jpg new file mode 100644 index 0000000..d7ede42 Binary files /dev/null and b/data/images/portraits/thumbs/Joan_Mir__thumb.jpg differ diff --git a/data/images/portraits/thumbs/John_Constable_thumb.jpg b/data/images/portraits/thumbs/John_Constable_thumb.jpg new file mode 100644 index 0000000..02e5239 Binary files /dev/null and b/data/images/portraits/thumbs/John_Constable_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Juan_Gris_thumb.jpg b/data/images/portraits/thumbs/Juan_Gris_thumb.jpg new file mode 100644 index 0000000..b4db852 Binary files /dev/null and b/data/images/portraits/thumbs/Juan_Gris_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Kazimir_Malevich_thumb.jpg b/data/images/portraits/thumbs/Kazimir_Malevich_thumb.jpg new file mode 100644 index 0000000..f43d844 Binary files /dev/null and b/data/images/portraits/thumbs/Kazimir_Malevich_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Leonardo_da_Vinci_thumb.jpg b/data/images/portraits/thumbs/Leonardo_da_Vinci_thumb.jpg new file mode 100644 index 0000000..a698e30 Binary files /dev/null and b/data/images/portraits/thumbs/Leonardo_da_Vinci_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Lyubov_Popova_thumb.jpg b/data/images/portraits/thumbs/Lyubov_Popova_thumb.jpg new file mode 100644 index 0000000..5513dbf Binary files /dev/null and b/data/images/portraits/thumbs/Lyubov_Popova_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Marcel_Duchamp_thumb.jpg b/data/images/portraits/thumbs/Marcel_Duchamp_thumb.jpg new file mode 100644 index 0000000..d575ef7 Binary files /dev/null and b/data/images/portraits/thumbs/Marcel_Duchamp_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Mark_Rothko_thumb.jpg b/data/images/portraits/thumbs/Mark_Rothko_thumb.jpg new file mode 100644 index 0000000..ff5f572 Binary files /dev/null and b/data/images/portraits/thumbs/Mark_Rothko_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Masaccio_thumb.jpg b/data/images/portraits/thumbs/Masaccio_thumb.jpg new file mode 100644 index 0000000..e89997f Binary files /dev/null and b/data/images/portraits/thumbs/Masaccio_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Maurice_de_Vlaminck_thumb.jpg b/data/images/portraits/thumbs/Maurice_de_Vlaminck_thumb.jpg new file mode 100644 index 0000000..580a68c Binary files /dev/null and b/data/images/portraits/thumbs/Maurice_de_Vlaminck_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Max_Ernst_thumb.jpg b/data/images/portraits/thumbs/Max_Ernst_thumb.jpg new file mode 100644 index 0000000..0b4e322 Binary files /dev/null and b/data/images/portraits/thumbs/Max_Ernst_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Michelangelo_thumb.jpg b/data/images/portraits/thumbs/Michelangelo_thumb.jpg new file mode 100644 index 0000000..50f4433 Binary files /dev/null and b/data/images/portraits/thumbs/Michelangelo_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Nicolas_Poussin_thumb.jpg b/data/images/portraits/thumbs/Nicolas_Poussin_thumb.jpg new file mode 100644 index 0000000..4a0d1a6 Binary files /dev/null and b/data/images/portraits/thumbs/Nicolas_Poussin_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Odilon_Redon_thumb.jpg b/data/images/portraits/thumbs/Odilon_Redon_thumb.jpg new file mode 100644 index 0000000..4e2aa35 Binary files /dev/null and b/data/images/portraits/thumbs/Odilon_Redon_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Olga_Rozanova_thumb.jpg b/data/images/portraits/thumbs/Olga_Rozanova_thumb.jpg new file mode 100644 index 0000000..4294937 Binary files /dev/null and b/data/images/portraits/thumbs/Olga_Rozanova_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Pablo_Picasso_thumb.jpg b/data/images/portraits/thumbs/Pablo_Picasso_thumb.jpg new file mode 100644 index 0000000..d2cc26e Binary files /dev/null and b/data/images/portraits/thumbs/Pablo_Picasso_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Parmigianino_thumb.jpg b/data/images/portraits/thumbs/Parmigianino_thumb.jpg new file mode 100644 index 0000000..cc3b68a Binary files /dev/null and b/data/images/portraits/thumbs/Parmigianino_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Parrhasius_thumb.jpg b/data/images/portraits/thumbs/Parrhasius_thumb.jpg new file mode 100644 index 0000000..9875078 Binary files /dev/null and b/data/images/portraits/thumbs/Parrhasius_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Paul_C_zanne_thumb.jpg b/data/images/portraits/thumbs/Paul_C_zanne_thumb.jpg new file mode 100644 index 0000000..8a14e70 Binary files /dev/null and b/data/images/portraits/thumbs/Paul_C_zanne_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Paul_Gauguin_thumb.jpg b/data/images/portraits/thumbs/Paul_Gauguin_thumb.jpg new file mode 100644 index 0000000..4e4d296 Binary files /dev/null and b/data/images/portraits/thumbs/Paul_Gauguin_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Peter_Paul_Rubens_thumb.jpg b/data/images/portraits/thumbs/Peter_Paul_Rubens_thumb.jpg new file mode 100644 index 0000000..18d87ea Binary files /dev/null and b/data/images/portraits/thumbs/Peter_Paul_Rubens_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Pierre-Auguste_Renoir_thumb.jpg b/data/images/portraits/thumbs/Pierre-Auguste_Renoir_thumb.jpg new file mode 100644 index 0000000..82866ca Binary files /dev/null and b/data/images/portraits/thumbs/Pierre-Auguste_Renoir_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Pierre_Puvis_de_Chavannes_thumb.jpg b/data/images/portraits/thumbs/Pierre_Puvis_de_Chavannes_thumb.jpg new file mode 100644 index 0000000..ea40749 Binary files /dev/null and b/data/images/portraits/thumbs/Pierre_Puvis_de_Chavannes_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Pontormo_thumb.jpg b/data/images/portraits/thumbs/Pontormo_thumb.jpg new file mode 100644 index 0000000..3fc2ec2 Binary files /dev/null and b/data/images/portraits/thumbs/Pontormo_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Raphael_thumb.jpg b/data/images/portraits/thumbs/Raphael_thumb.jpg new file mode 100644 index 0000000..240c2f6 Binary files /dev/null and b/data/images/portraits/thumbs/Raphael_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Rembrandt_thumb.jpg b/data/images/portraits/thumbs/Rembrandt_thumb.jpg new file mode 100644 index 0000000..338f173 Binary files /dev/null and b/data/images/portraits/thumbs/Rembrandt_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Ren__Magritte_thumb.jpg b/data/images/portraits/thumbs/Ren__Magritte_thumb.jpg new file mode 100644 index 0000000..d55b3ed Binary files /dev/null and b/data/images/portraits/thumbs/Ren__Magritte_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Richard_Hamilton_thumb.jpg b/data/images/portraits/thumbs/Richard_Hamilton_thumb.jpg new file mode 100644 index 0000000..d5d705a Binary files /dev/null and b/data/images/portraits/thumbs/Richard_Hamilton_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Roy_Lichtenstein_thumb.jpg b/data/images/portraits/thumbs/Roy_Lichtenstein_thumb.jpg new file mode 100644 index 0000000..f7451ba Binary files /dev/null and b/data/images/portraits/thumbs/Roy_Lichtenstein_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Salvador_Dal__thumb.jpg b/data/images/portraits/thumbs/Salvador_Dal__thumb.jpg new file mode 100644 index 0000000..1c67bef Binary files /dev/null and b/data/images/portraits/thumbs/Salvador_Dal__thumb.jpg differ diff --git a/data/images/portraits/thumbs/Sandro_Botticelli_thumb.jpg b/data/images/portraits/thumbs/Sandro_Botticelli_thumb.jpg new file mode 100644 index 0000000..338f173 Binary files /dev/null and b/data/images/portraits/thumbs/Sandro_Botticelli_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Simone_Martini_thumb.jpg b/data/images/portraits/thumbs/Simone_Martini_thumb.jpg new file mode 100644 index 0000000..d288802 Binary files /dev/null and b/data/images/portraits/thumbs/Simone_Martini_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Th_odore_G_ricault_thumb.jpg b/data/images/portraits/thumbs/Th_odore_G_ricault_thumb.jpg new file mode 100644 index 0000000..5bf7818 Binary files /dev/null and b/data/images/portraits/thumbs/Th_odore_G_ricault_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Theophanes_the_Greek_thumb.jpg b/data/images/portraits/thumbs/Theophanes_the_Greek_thumb.jpg new file mode 100644 index 0000000..7d5ebdb Binary files /dev/null and b/data/images/portraits/thumbs/Theophanes_the_Greek_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Titian_thumb.jpg b/data/images/portraits/thumbs/Titian_thumb.jpg new file mode 100644 index 0000000..c3ae38c Binary files /dev/null and b/data/images/portraits/thumbs/Titian_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Umberto_Boccioni_thumb.jpg b/data/images/portraits/thumbs/Umberto_Boccioni_thumb.jpg new file mode 100644 index 0000000..fb3af1a Binary files /dev/null and b/data/images/portraits/thumbs/Umberto_Boccioni_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Vincent_van_Gogh_thumb.jpg b/data/images/portraits/thumbs/Vincent_van_Gogh_thumb.jpg new file mode 100644 index 0000000..eb2ce25 Binary files /dev/null and b/data/images/portraits/thumbs/Vincent_van_Gogh_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Vladimir_Tatlin_thumb.jpg b/data/images/portraits/thumbs/Vladimir_Tatlin_thumb.jpg new file mode 100644 index 0000000..748287b Binary files /dev/null and b/data/images/portraits/thumbs/Vladimir_Tatlin_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Wassily_Kandinsky_thumb.jpg b/data/images/portraits/thumbs/Wassily_Kandinsky_thumb.jpg new file mode 100644 index 0000000..e51df87 Binary files /dev/null and b/data/images/portraits/thumbs/Wassily_Kandinsky_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Willem_de_Kooning_thumb.jpg b/data/images/portraits/thumbs/Willem_de_Kooning_thumb.jpg new file mode 100644 index 0000000..2e0ad02 Binary files /dev/null and b/data/images/portraits/thumbs/Willem_de_Kooning_thumb.jpg differ diff --git a/data/images/portraits/thumbs/Zeuxis_thumb.jpg b/data/images/portraits/thumbs/Zeuxis_thumb.jpg new file mode 100644 index 0000000..d55b3ed Binary files /dev/null and b/data/images/portraits/thumbs/Zeuxis_thumb.jpg differ diff --git a/data/images/portraits/thumbs/_douard_Manet_thumb.jpg b/data/images/portraits/thumbs/_douard_Manet_thumb.jpg new file mode 100644 index 0000000..7cf6e02 Binary files /dev/null and b/data/images/portraits/thumbs/_douard_Manet_thumb.jpg differ diff --git a/db/migrate-perf-indexes.sql b/db/migrate-perf-indexes.sql new file mode 100644 index 0000000..9c4aa22 --- /dev/null +++ b/db/migrate-perf-indexes.sql @@ -0,0 +1,4 @@ +-- Query performance for timeline filters and influence lookups +CREATE INDEX IF NOT EXISTS idx_artists_birth_year ON artists(birth_year); +CREATE INDEX IF NOT EXISTS idx_artists_death_year ON artists(death_year); +CREATE INDEX IF NOT EXISTS idx_influence_sources_source_painting ON painting_influence_sources(source_painting_id); diff --git a/db/migrate-portrait-thumbs.sql b/db/migrate-portrait-thumbs.sql new file mode 100644 index 0000000..a790bec --- /dev/null +++ b/db/migrate-portrait-thumbs.sql @@ -0,0 +1,2 @@ +-- Timeline portrait thumbnails (~256px) under portraits/thumbs/ +ALTER TABLE artists ADD COLUMN IF NOT EXISTS portrait_thumb_path TEXT; diff --git a/db/schema.sql b/db/schema.sql index 3f8c11f..691d782 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS artists ( death_year INTEGER, movement_id INTEGER REFERENCES art_movements(id), portrait_path VARCHAR(500), + portrait_thumb_path VARCHAR(500), bio_short TEXT, bio_full TEXT, wikipedia_title VARCHAR(300), @@ -121,6 +122,8 @@ CREATE TABLE IF NOT EXISTS painting_annotations ( ); CREATE INDEX IF NOT EXISTS idx_artists_movement ON artists(movement_id); +CREATE INDEX IF NOT EXISTS idx_artists_birth_year ON artists(birth_year); +CREATE INDEX IF NOT EXISTS idx_artists_death_year ON artists(death_year); CREATE INDEX IF NOT EXISTS idx_artists_century ON artists(century); CREATE INDEX IF NOT EXISTS artists_checkup_checked_idx ON artists (checkup_checked); CREATE INDEX IF NOT EXISTS artists_checkup_fixed_idx ON artists (checkup_fixed); @@ -149,6 +152,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS painting_influence_sources_unique CREATE INDEX IF NOT EXISTS painting_influence_sources_painting_idx ON painting_influence_sources (painting_id); +CREATE INDEX IF NOT EXISTS idx_influence_sources_source_painting + ON painting_influence_sources (source_painting_id); + CREATE INDEX IF NOT EXISTS painting_influence_sources_artist_idx ON painting_influence_sources (source_artist_id); diff --git a/package-lock.json b/package-lock.json index e594746..0f2b662 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "bcryptjs": "^3.0.3", + "compression": "^1.8.1", "connect-pg-simple": "^10.0.0", "cors": "^2.8.6", "dotenv": "^17.4.2", @@ -765,6 +766,60 @@ "fsevents": "~2.3.2" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/connect-pg-simple": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", diff --git a/package.json b/package.json index 9af3d33..de9940a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "fetch-artist-bios": "node scripts/fetch-artist-bios.js", "sync-image-paths": "node scripts/sync-image-paths.js", "regenerate-thumbnails": "node scripts/regenerate-thumbnails.js", + "regenerate-portrait-thumbs": "node scripts/regenerate-portrait-thumbs.js", "audit-painting-images": "node scripts/audit-painting-images.js", "expand-catalog": "node scripts/expand-paintings.js", "update-influences": "node scripts/update-influences.js", @@ -55,6 +56,7 @@ "dependencies": { "bcryptjs": "^3.0.3", "connect-pg-simple": "^10.0.0", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", diff --git a/scripts/regenerate-portrait-thumbs.js b/scripts/regenerate-portrait-thumbs.js new file mode 100644 index 0000000..ba87d49 --- /dev/null +++ b/scripts/regenerate-portrait-thumbs.js @@ -0,0 +1,76 @@ +/** + * Generate timeline portrait thumbnails from existing full portraits. + * Writes portraits/thumbs/{Artist}_thumb.jpg and updates portrait_thumb_path. + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const pool = require('../server/db'); +const { generateThumbnailFromFull } = require('./image-fetcher'); + +const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images'); +const PORTRAIT_THUMB_WIDTH = 256; + +function safeArtistPortraitBase(artistName) { + return artistName.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function localPath(rel) { + return path.join(IMAGE_DIR, rel); +} + +async function generatePortraitThumb(row) { + if (!row.portrait_path) return { skipped: true, reason: 'no portrait' }; + + const fullPath = localPath(row.portrait_path); + if (!fs.existsSync(fullPath)) return { skipped: true, reason: 'missing file' }; + + const safeBase = safeArtistPortraitBase(row.name); + const thumbsDir = path.join(IMAGE_DIR, 'portraits', 'thumbs'); + if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); + + const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`); + await generateThumbnailFromFull(fullPath, thumbDest, PORTRAIT_THUMB_WIDTH); + const portraitThumbPath = path.join('portraits', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); + + await pool.query(`UPDATE artists SET portrait_thumb_path = $1 WHERE id = $2`, [ + portraitThumbPath, + row.id, + ]); + + return { skipped: false, portraitThumbPath }; +} + +(async () => { + const { rows } = await pool.query( + `SELECT id, name, portrait_path, portrait_thumb_path FROM artists + WHERE portrait_path IS NOT NULL AND portrait_path <> '' + ORDER BY id` + ); + + let generated = 0; + let skipped = 0; + const errors = []; + + for (const row of rows) { + try { + const result = await generatePortraitThumb(row); + if (result.skipped) skipped++; + else generated++; + } catch (err) { + errors.push({ id: row.id, name: row.name, error: err.message }); + } + } + + console.log(`Portrait thumbs: ${generated} generated, ${skipped} skipped, ${errors.length} errors`); + if (errors.length) { + for (const e of errors.slice(0, 20)) { + console.warn(` #${e.id} ${e.name}: ${e.error}`); + } + } + + await pool.end(); +})().catch((err) => { + console.error(err); + pool.end().finally(() => process.exit(1)); +}); diff --git a/server/image-service.js b/server/image-service.js index 70f0a4e..7c543d4 100644 --- a/server/image-service.js +++ b/server/image-service.js @@ -10,6 +10,7 @@ const { const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images'); const FETCH_TIMEOUT_MS = 15000; +const PORTRAIT_THUMB_WIDTH = 256; const inflight = new Map(); function safePaintingBase(artistName, title) { @@ -183,10 +184,34 @@ function unlinkPaintingFiles(row, safeBase) { function unlinkPortraitFiles(row, safeBase) { unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); + unlinkIfExists(row.portrait_thumb_path ? path.join(IMAGE_DIR, row.portrait_thumb_path) : null); const portraitsDir = path.join(IMAGE_DIR, 'portraits'); + const thumbsDir = path.join(portraitsDir, 'thumbs'); for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { unlinkIfExists(path.join(portraitsDir, safeBase + ext)); } + unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg')); + unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + '.jpeg')); +} + +async function writePortraitThumb(fullPath, safeBase) { + const thumbsDir = path.join(IMAGE_DIR, 'portraits', 'thumbs'); + if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); + const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`); + try { + await generateThumbnailFromFull(fullPath, thumbDest, PORTRAIT_THUMB_WIDTH); + return path.join('portraits', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); + } catch { + return null; + } +} + +async function updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath) { + await pool.query( + `UPDATE artists SET portrait_path = $1, portrait_thumb_path = $2 WHERE id = $3`, + [portraitPath, portraitThumbPath, artistId] + ); + return { portraitPath, portraitThumbPath }; } async function deletePainting(paintingId) { @@ -239,7 +264,7 @@ async function clearPaintingImage(paintingId) { async function clearArtistPortrait(artistId) { const result = await pool.query( - `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + `SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { @@ -250,9 +275,12 @@ async function clearArtistPortrait(artistId) { const safeBase = safeArtistPortraitBase(row.name); unlinkPortraitFiles(row, safeBase); - await pool.query(`UPDATE artists SET portrait_path = NULL WHERE id = $1`, [artistId]); + await pool.query( + `UPDATE artists SET portrait_path = NULL, portrait_thumb_path = NULL WHERE id = $1`, + [artistId] + ); - return { portraitPath: null }; + return { portraitPath: null, portraitThumbPath: null }; } async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) { @@ -323,7 +351,7 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) { } const result = await pool.query( - `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + `SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { @@ -349,14 +377,13 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) { const fullDest = path.join(portraitsDir, safeBase + fullExt); fs.writeFileSync(fullDest, buffer); const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); - await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); - return { portraitPath }; + const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath; + return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); } const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); - await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); - - return { portraitPath }; + const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath; + return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); } async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { @@ -409,7 +436,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { const result = await pool.query( - `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + `SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { @@ -424,10 +451,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { const fullExt = pickExt(imageUrl); const fullDest = path.join(portraitsDir, safeBase + fullExt); - unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); - for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) { - unlinkIfExists(path.join(portraitsDir, safeBase + ext)); - } + unlinkPortraitFiles(row, safeBase); await downloadImageForFix(imageUrl, fullDest, context); @@ -449,9 +473,9 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { // keep downloaded file as-is } - await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); - - return { portraitPath }; + const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath); + const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath; + return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); } module.exports = { diff --git a/server/index.js b/server/index.js index 3d42e0c..8b65a3a 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,7 @@ const express = require('express'); const cors = require('cors'); +const compression = require('compression'); +const crypto = require('crypto'); const path = require('path'); const fs = require('fs'); require('dotenv').config(); @@ -22,10 +24,98 @@ if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') { } app.use(cors({ origin: true, credentials: true })); +app.use(compression()); app.use(express.json({ limit: '20mb' })); app.use(createSessionMiddleware()); app.use('/api/auth', authRoutes); -app.use('/images', express.static(IMAGE_DIR)); +app.use( + '/images', + express.static(IMAGE_DIR, { + maxAge: '365d', + immutable: true, + setHeaders(res) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + }, + }) +); + +const TIMELINE_ARTIST_SELECT = ` + SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path, + a.portrait_thumb_path, a.wikipedia_title, a.century, + m.name as movement_name, m.color as movement_color + FROM artists a + LEFT JOIN art_movements m ON a.movement_id = m.id + WHERE 1=1`; + +async function fetchCatalogBounds() { + const result = await pool.query(` + SELECT + (SELECT MIN(start_year) FROM art_movements) as min_year, + GREATEST( + (SELECT MAX(end_year) FROM historical_eras), + (SELECT MAX(end_year) FROM art_movements), + (SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL) + ) as max_year + `); + return result.rows[0]; +} + +async function fetchTimelineErasAndMovements(startYear, endYear) { + const [eras, movements] = await Promise.all([ + pool.query( + `SELECT * FROM historical_eras + WHERE end_year >= $1 AND start_year <= $2 + ORDER BY sort_order, start_year`, + [startYear, endYear] + ), + pool.query( + `SELECT DISTINCT m.*, e.name as era_name + FROM art_movements m + LEFT JOIN historical_eras e ON m.era_id = e.id + INNER JOIN artists a ON a.movement_id = m.id + WHERE m.end_year >= $1 AND m.start_year <= $2 + AND (a.death_year IS NULL OR a.death_year >= $1) + AND (a.birth_year IS NULL OR a.birth_year <= $2) + ORDER BY m.start_year`, + [startYear, endYear] + ), + ]); + return { eras: eras.rows, movements: movements.rows }; +} + +async function fetchTimelineArtists(startYear, endYear) { + let query = TIMELINE_ARTIST_SELECT; + const params = []; + if (startYear != null) { + params.push(startYear); + query += ` AND (a.death_year IS NULL OR a.death_year >= $${params.length})`; + } + if (endYear != null) { + params.push(endYear); + query += ` AND (a.birth_year IS NULL OR a.birth_year <= $${params.length})`; + } + query += ' ORDER BY a.birth_year'; + const result = await pool.query(query, params); + return result.rows; +} + +async function catalogBootstrapEtag() { + const result = await pool.query(` + SELECT + (SELECT COUNT(*)::int FROM artists) AS artist_count, + (SELECT COUNT(*)::int FROM art_movements) AS movement_count, + (SELECT COUNT(*)::int FROM historical_eras) AS era_count, + (SELECT COUNT(*)::int FROM paintings) AS painting_count + `); + return result.rows[0]; +} + +function sendCatalogCacheHeaders(res, etagSource) { + const etag = `"${crypto.createHash('md5').update(JSON.stringify(etagSource)).digest('hex')}"`; + res.setHeader('Cache-Control', 'public, max-age=300'); + res.setHeader('ETag', etag); + return etag; +} const INFLUENCE_LINKS_EXISTS = ` EXISTS ( @@ -96,33 +186,39 @@ app.get('/api/timeline', async (req, res) => { const startYear = parseInt(start) || -3000; const endYear = parseInt(end) || 2100; - const [eras, movements] = await Promise.all([ - pool.query( - `SELECT * FROM historical_eras - WHERE end_year >= $1 AND start_year <= $2 - ORDER BY sort_order, start_year`, - [startYear, endYear] - ), - pool.query( - `SELECT DISTINCT m.*, e.name as era_name - FROM art_movements m - LEFT JOIN historical_eras e ON m.era_id = e.id - INNER JOIN artists a ON a.movement_id = m.id - WHERE m.end_year >= $1 AND m.start_year <= $2 - AND (a.death_year IS NULL OR a.death_year >= $1) - AND (a.birth_year IS NULL OR a.birth_year <= $2) - ORDER BY m.start_year`, - [startYear, endYear] - ), - ]); - - res.json({ eras: eras.rows, movements: movements.rows }); + const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear); + res.json({ eras, movements }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to fetch timeline' }); } }); +// Single request for timeline first paint: bounds + eras + movements + slim artists +app.get('/api/catalog/bootstrap', async (req, res) => { + try { + const bounds = await fetchCatalogBounds(); + const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000; + const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100; + + const etagSource = await catalogBootstrapEtag(); + const etag = sendCatalogCacheHeaders(res, etagSource); + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + const [{ eras, movements }, artists] = await Promise.all([ + fetchTimelineErasAndMovements(startYear, endYear), + fetchTimelineArtists(startYear, endYear), + ]); + + res.json({ bounds, eras, movements, artists }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch catalog bootstrap' }); + } +}); + // Movement gallery — all paintings by artists in the movement, chronological app.get('/api/movements/:id/gallery', async (req, res) => { try { @@ -184,13 +280,7 @@ app.get('/api/artists', async (req, res) => { const { start, end, movement_id, timeline } = req.query; const timelineOnly = timeline === '1' || timeline === 'true'; let query = timelineOnly - ? ` - SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path, - a.bio_short, a.wikipedia_title, a.century, - m.name as movement_name, m.color as movement_color - FROM artists a - LEFT JOIN art_movements m ON a.movement_id = m.id - WHERE 1=1` + ? TIMELINE_ARTIST_SELECT : ` SELECT a.*, m.name as movement_name, m.color as movement_color FROM artists a @@ -941,16 +1031,8 @@ app.get('/api/paintings/:id/image', async (req, res) => { // Year range bounds app.get('/api/bounds', async (req, res) => { try { - const result = await pool.query(` - SELECT - (SELECT MIN(start_year) FROM art_movements) as min_year, - GREATEST( - (SELECT MAX(end_year) FROM historical_eras), - (SELECT MAX(end_year) FROM art_movements), - (SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL) - ) as max_year - `); - res.json(result.rows[0]); + const bounds = await fetchCatalogBounds(); + res.json(bounds); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to fetch bounds' }); diff --git a/server/migrate.js b/server/migrate.js index c1a8896..2ee98cc 100644 --- a/server/migrate.js +++ b/server/migrate.js @@ -10,6 +10,8 @@ const INCREMENTAL_MIGRATIONS = [ 'migrate-painting-annotations.sql', 'migrate-artist-palette.sql', 'migrate-auth.sql', + 'migrate-portrait-thumbs.sql', + 'migrate-perf-indexes.sql', ]; async function bootstrapCurator() {