Improve 3D gallery and debug checkup workflow for image curation.

Add parquet floor, museum-style exit, movement-tinted walls, and gold/black
frames with gallery sync after Fix it. Debug panel gets Checked and Fix it
buttons; documentation and image-fetch reliability updates included.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-20 22:05:24 +03:00
co-authored by Cursor
parent 4c6acd5a3a
commit 019ce4e136
67 changed files with 1061 additions and 190 deletions
+35 -5
View File
@@ -30,6 +30,19 @@ export function galleryImageUrl(painting: {
return null;
}
export function galleryImageUrlWithRevision(
painting: {
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
},
revision?: number
): string | null {
const base = galleryImageUrl(painting);
if (!base || !revision) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
export function paintingImageUrl(painting: {
id: number;
image_path?: string | null;
@@ -46,6 +59,13 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export interface FixPaintingImageResult {
imagePath: string;
thumbnailPath: string;
fixed?: boolean;
checked?: boolean;
}
export interface DebugImageSearchResult {
query: string;
imageUrl: string | null;
@@ -100,17 +120,21 @@ export const api = {
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
fixPaintingImage: (id: number, imageUrl: string) =>
fixPaintingImage: (
id: number,
imageUrl: string,
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/paintings/${id}/fix-image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
body: JSON.stringify({ imageUrl, ...context }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>;
return res.json() as Promise<FixPaintingImageResult>;
}),
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
@@ -132,6 +156,12 @@ export const api = {
}),
};
export function debugImageProxyUrl(imageUrl: string): string {
return `${API}/debug/image-proxy?url=${encodeURIComponent(imageUrl)}`;
export function debugImageProxyUrl(
imageUrl: string,
context?: { searchUrl?: string; source?: string }
): string {
const params = new URLSearchParams({ url: imageUrl });
if (context?.searchUrl) params.set('searchUrl', context.searchUrl);
if (context?.source) params.set('source', context.source);
return `${API}/debug/image-proxy?${params.toString()}`;
}
+29 -1
View File
@@ -440,6 +440,34 @@
word-break: break-word;
}
.debug-action-buttons {
display: flex;
gap: 8px;
margin-top: 4px;
}
.debug-checked-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid rgba(201, 169, 110, 0.45);
border-radius: 6px;
background: rgba(201, 169, 110, 0.1);
color: #c9a96e;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.debug-checked-btn:hover:not(:disabled) {
background: rgba(201, 169, 110, 0.22);
border-color: #c9a96e;
}
.debug-checked-btn:disabled {
opacity: 0.55;
cursor: default;
}
.debug-image-preview {
display: block;
width: 100%;
@@ -451,7 +479,7 @@
}
.debug-fix-btn {
width: 100%;
flex: 1;
padding: 8px 10px;
border: 1px solid #e8a040;
border-radius: 6px;
+62 -22
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type SyntheticEvent } from 'react';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult } from '../api/client';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
@@ -13,7 +13,14 @@ interface Props {
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
debugMode?: boolean;
onPaintingImageFixed?: (paintingId: number) => void | Promise<void>;
onPaintingImageFixed?: (
paintingId: number,
fixResult: FixPaintingImageResult
) => void | Promise<void>;
onPaintingCheckupFlagsUpdated?: (
paintingId: number,
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
}
function influenceKey(inf: InfluenceLink, index: number): string {
@@ -180,6 +187,7 @@ export default function PaintingDetailView({
onInfluenceArtistClick,
debugMode = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
}: Props) {
const { painting, influencedBy, influenced } = data;
const [fullscreen, setFullscreen] = useState(false);
@@ -188,6 +196,7 @@ export default function PaintingDetailView({
const [debugLoading, setDebugLoading] = useState(false);
const [debugError, setDebugError] = useState<string | null>(null);
const [fixing, setFixing] = useState(false);
const [markingChecked, setMarkingChecked] = useState(false);
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
@@ -237,9 +246,15 @@ export default function PaintingDetailView({
setFixing(true);
setDebugError(null);
try {
await api.fixPaintingImage(painting.id, debugSearch.imageUrl);
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
thumbUrl: debugSearch.thumbUrl,
});
setImageVersion((v) => v + 1);
await onPaintingImageFixed?.(painting.id);
if (onPaintingImageFixed) {
await onPaintingImageFixed(painting.id, fixResult);
}
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
} finally {
@@ -247,6 +262,20 @@ export default function PaintingDetailView({
}
};
const handleMarkChecked = async () => {
if (painting.checkup_checked || markingChecked) return;
setMarkingChecked(true);
setDebugError(null);
try {
const updated = await api.updatePaintingCheckupFlags(painting.id, { checked: true });
await onPaintingCheckupFlagsUpdated?.(painting.id, updated);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
} finally {
setMarkingChecked(false);
}
};
useEffect(() => {
if (fullscreen) return;
@@ -391,28 +420,39 @@ export default function PaintingDetailView({
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
<>
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl)}
alt={`Google search result for ${debugSearch.query}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<button
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing}
>
{fixing ? 'Replacing…' : 'Fix it'}
</button>
</>
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
})}
alt={`Google search result for ${debugSearch.query}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
>
{markingChecked ? '…' : 'Checked'}
</button>
<button
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
</div>
</aside>
)}
+505 -95
View File
@@ -9,12 +9,14 @@ import type {
ArtistNavigation,
MovementArtistGroup,
} from '../types';
import { galleryImageUrl, imageUrl, api, preloadArtistImages } from '../api/client';
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils';
import { createParquetFloorTexture, PARQUET_METERS_PER_TILE } from '../utils/parquetFloorTexture';
import './VirtualGallery.css';
interface Props {
data: ArtistDetail;
imageRevisions?: Record<number, number>;
active?: boolean;
onPaintingClick: (paintingId: number) => void;
onNavigateArtist: (artistId: number) => void;
@@ -28,20 +30,66 @@ const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const BACK_WALL_EXTRA = 0.05;
const FRAME_FACE_Z = 0.018;
const FRAME_DEPTH = 0.07;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
/** Checked paintings — double-width moulding. */
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2;
const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.18;
const FRAME_GAP = 0.32;
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
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;
const DOOR_HEIGHT = 2.5;
/** Museum exit — dimensions derived from door opening. */
const EXIT_JAMB = 0.13;
const EXIT_HEADER = 0.15;
const EXIT_TRANSOM = 0.52;
const EXIT_SURROUND = 0.1;
const TURN_SPEED = 0.032;
const MOUSE_TURN_SENSITIVITY = 0.004;
const DRAG_START_THRESHOLD_PX = 5;
const DEFAULT_MOVEMENT_COLOR = '#8B7355';
const WALL_BASE_MAIN = '#f0ebe3';
const WALL_BASE_SIDE = '#e8e2d8';
function parseHex(hex: string): { r: number; g: number; b: number } {
const n = parseInt(hex.replace('#', ''), 16);
if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 };
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function blendHex(base: string, accent: string, accentWeight: number): string {
const a = parseHex(accent);
const b = parseHex(base);
const w = Math.min(1, Math.max(0, accentWeight));
const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w));
const r = mix(a.r, b.r);
const g = mix(a.g, b.g);
const bl = mix(a.b, b.b);
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
}
function galleryWallColors(movementColor?: string) {
const accent = movementColor || DEFAULT_MOVEMENT_COLOR;
return {
main: blendHex(WALL_BASE_MAIN, accent, 0.34),
side: blendHex(WALL_BASE_SIDE, accent, 0.26),
trim: blendHex('#b8956a', accent, 0.45),
};
}
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
@@ -68,34 +116,73 @@ interface HallLayout {
segments: WallSegment[];
}
function layoutRow(count: number, span: number) {
function paintingIsReviewed(painting: Painting): boolean {
return !!painting.checkup_checked;
}
function frameDimsForReviewed(reviewed: boolean) {
return reviewed
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 }
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH };
}
function frameOuterW(canvasW: number, reviewed: boolean): number {
const { matBorder, rail } = frameDimsForReviewed(reviewed);
return canvasW + matBorder * 2 + rail;
}
function frameOuterH(canvasH: number, reviewed: boolean): number {
const { matBorder, rail } = frameDimsForReviewed(reviewed);
return canvasH + matBorder * 2 + rail;
}
function layoutRow(paintings: Painting[], span: number) {
const count = paintings.length;
if (count === 0) return { slots: [], spanNeeded: span };
const gap = FRAME_GAP;
const padding = 1.4;
const available = span - padding;
let frameW = Math.min(MAX_FRAME_W, (available - (count - 1) * gap) / Math.max(count, 1));
const available = span - WALL_PADDING;
let frameW = MAX_FRAME_W;
for (let attempt = 0; attempt < 40; attempt++) {
let total = 0;
for (let i = 0; i < count; i++) {
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
if (i < count - 1) total += gap;
}
if (total <= available) break;
frameW -= 0.015;
}
frameW = Math.max(MIN_FRAME_W, frameW);
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
const rowWidth = count * frameW + (count - 1) * gap;
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap;
const slots: { offset: number; maxW: number; maxH: number }[] = [];
let cursor = -rowWidth / 2;
for (let i = 0; i < count; i++) {
const outerW = outers[i];
slots.push({
offset: -rowWidth / 2 + frameW / 2 + i * (frameW + gap),
offset: cursor + outerW / 2,
maxW: frameW,
maxH: frameH,
});
cursor += outerW + gap;
}
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function wallRowHeights(count: number, span: number, rows: number) {
const perRow = Math.ceil(count / rows);
function wallRowHeights(paintings: Painting[], span: number, rows: number) {
const perRow = Math.ceil(paintings.length / rows);
const heights: number[] = [];
for (let r = 0; r < rows; r++) {
const inRow = Math.min(perRow, count - r * perRow);
const { slots } = layoutRow(inRow, span);
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
const rowStart = r * perRow;
const row = paintings.slice(rowStart, rowStart + perRow);
const { slots } = layoutRow(row, span);
const reviewed = row.some(paintingIsReviewed);
heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed));
}
return heights;
}
@@ -104,16 +191,14 @@ function wallStackHeight(heights: number[]) {
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
}
function fitsOnWall(count: number, span: number, rows: number) {
function fitsOnWall(paintings: Painting[], span: number, rows: number) {
const count = paintings.length;
const perRow = Math.ceil(count / rows);
const available = span - WALL_PADDING;
const frameW = Math.min(
MAX_FRAME_W,
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
);
if (frameW < MIN_FRAME_W) return false;
const row = paintings.slice(0, perRow);
const { slots } = layoutRow(row, span);
if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false;
const totalHeight = wallStackHeight(wallRowHeights(count, span, rows));
const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows));
const bottom = EYE_HEIGHT - totalHeight / 2;
const top = EYE_HEIGHT + totalHeight / 2;
if (bottom < 0.35) return false;
@@ -121,26 +206,35 @@ function fitsOnWall(count: number, span: number, rows: number) {
return true;
}
function rowCountForWall(count: number, span: number) {
for (let rows = 1; rows <= count; rows++) {
if (fitsOnWall(count, span, rows)) return rows;
function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) {
const count = paintings.length;
const limit = Math.max(1, Math.min(count, maxRows));
for (let rows = 1; rows <= limit; rows++) {
if (fitsOnWall(paintings, span, rows)) return rows;
}
return count;
return limit;
}
function minSpanForWall(count: number) {
function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) {
const count = paintings.length;
if (count === 0) return MIN_HALL_SIZE;
let best = Infinity;
for (let rows = 1; rows <= count; rows++) {
const rowLimit = Math.max(1, Math.min(count, maxRows));
for (let rows = 1; rows <= rowLimit; rows++) {
const perRow = Math.ceil(count / rows);
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
if (fitsOnWall(count, span, rows)) {
best = Math.min(best, span);
const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE);
if (fitsOnWall(paintings, spanNeeded, rows)) {
best = Math.min(best, spanNeeded);
}
}
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
if (best === Infinity) {
const perRow = Math.ceil(count / rowLimit);
best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded;
}
return Math.max(MIN_HALL_SIZE, best);
}
/** Left → right on each wall: later works on the left, earlier works on the right. */
@@ -150,9 +244,30 @@ function orderPaintingsForWallDisplay(paintings: Painting[]) {
function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort(comparePaintingsChronological);
const walls: Painting[][] = [[], [], []];
sorted.forEach((p, i) => walls[i % 3].push(p));
return walls.map(orderPaintingsForWallDisplay);
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);
});
}
return [
orderPaintingsForWallDisplay(back),
orderPaintingsForWallDisplay(left),
orderPaintingsForWallDisplay(right),
];
}
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
@@ -173,12 +288,13 @@ function layoutWallSlots(
side: WallSide,
halfW: number,
halfD: number,
inset: number
inset: number,
maxRows: number = paintings.length
): FrameSlot[] {
const count = paintings.length;
if (count === 0) return [];
const rows = rowCountForWall(count, span);
const rows = rowCountForWall(paintings, span, maxRows);
const perRow = Math.ceil(count / rows);
const slots: FrameSlot[] = [];
@@ -186,7 +302,7 @@ function layoutWallSlots(
for (let r = 0; r < rows; r++) {
const rowStart = r * perRow;
const inRow = Math.min(perRow, count - rowStart);
rowLayouts.push(layoutRow(inRow, span));
rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span));
}
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
@@ -236,14 +352,17 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
const walls: WallSide[] = ['back', 'left', 'right'];
const wallPaintings = distributePaintingsAcrossWalls(paintings);
let width = minSpanForWall(wallPaintings[0].length);
let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
let depth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
);
for (let i = 0; i < 24; i++) {
const nextWidth = minSpanForWall(wallPaintings[0].length);
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
const nextDepth = Math.max(
minSpanForWall(wallPaintings[1].length),
minSpanForWall(wallPaintings[2].length)
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
);
if (nextWidth === width && nextDepth === depth) break;
width = nextWidth;
@@ -267,7 +386,8 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
side,
halfW,
halfD,
inset
inset,
MAX_WALL_ROWS
),
}));
@@ -328,6 +448,25 @@ function getCanvasWeaveTexture() {
return canvasWeaveTexture;
}
function frameFinish(reviewed: boolean, hovered: boolean) {
if (reviewed) {
return {
color: hovered ? '#ffe566' : '#ffd700',
roughness: 0.22,
metalness: 0.78,
emissive: hovered ? '#ffcc00' : '#daa520',
emissiveIntensity: hovered ? 0.35 : 0.18,
};
}
return {
color: hovered ? '#2a2a2a' : '#0a0a0a',
roughness: 0.28,
metalness: 0.72,
emissive: '#000000',
emissiveIntensity: 0,
};
}
function CanvasCover({
width,
height,
@@ -510,6 +649,7 @@ function PaintingFrame({
maxWidth,
maxHeight,
wallSide,
imageRevision,
onClick,
}: {
painting: Painting;
@@ -518,19 +658,21 @@ function PaintingFrame({
maxWidth: number;
maxHeight: number;
wallSide: WallSide;
imageRevision?: number;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
const [aspect, setAspect] = useState(1.33);
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
const frameDepth = 0.06;
const matBorder = 0.05;
const reviewed = paintingIsReviewed(painting);
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
const hasImage = paintingHasGalleryImage(painting);
const url = hasImage ? galleryImageUrl(painting) : null;
const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
const { texture, failed } = usePaintingTexture(url);
const showImage = hasImage && !failed && !!texture;
const showCanvas = !showImage;
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
const finish = frameFinish(reviewed, hovered);
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
useEffect(() => {
@@ -563,18 +705,24 @@ function PaintingFrame({
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
<meshStandardMaterial
color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
roughness={0.45}
metalness={showCanvas ? 0.15 : 0.5}
color={finish.color}
roughness={finish.roughness}
metalness={finish.metalness}
emissive={finish.emissive}
emissiveIntensity={finish.emissiveIntensity}
/>
</mesh>
{!showCanvas && (
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
<meshStandardMaterial
color={reviewed ? '#fff6cc' : '#f5f0e6'}
roughness={reviewed ? 0.75 : 0.95}
metalness={reviewed ? 0.15 : 0}
/>
</mesh>
)}
@@ -637,69 +785,311 @@ function ExitPortal({
position,
active,
onActivate,
wallColor = '#f0ebe3',
trimColor = '#ddd5c8',
}: {
position: [number, number, number];
active: boolean;
onActivate: () => void;
wallColor?: string;
trimColor?: string;
}) {
const [hovered, setHovered] = useState(false);
const glow = active || hovered;
const highlight = active || hovered;
const openingW = DOOR_WIDTH;
const openingH = DOOR_HEIGHT;
const jamb = EXIT_JAMB;
const header = EXIT_HEADER;
const transomH = EXIT_TRANSOM;
const surround = EXIT_SURROUND;
const frameW = openingW + jamb * 2;
const frameFullH = openingH + header + transomH;
const leafW = (openingW - 0.025) / 2;
const leafH = openingH - 0.08;
const leafY = 0.04 + leafH / 2;
const faceZ = -0.04;
const woodDark = '#261a10';
const woodMid = '#3d2818';
const woodGrain = '#4e3624';
const stone = wallColor;
const stoneDark = trimColor;
const brass = highlight ? '#d4af37' : '#a08050';
const brassEmissive = highlight ? '#5a4010' : '#000000';
const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0';
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
e.stopPropagation();
onActivate();
};
const setHover = (on: boolean) => () => setHovered(on);
const doorLeaf = (side: 'left' | 'right') => {
const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006;
const panelInset = 0.06;
return (
<group key={side} position={[x, leafY, faceZ]}>
<mesh castShadow>
<boxGeometry args={[leafW, leafH, 0.055]} />
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
</mesh>
{/* Raised panel */}
<mesh position={[0, 0.08, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
<meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
</mesh>
<mesh position={[0, -leafH * 0.22, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
</mesh>
{/* Stile edges */}
<mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
<boxGeometry args={[0.05, leafH, 0.008]} />
<meshStandardMaterial color={woodDark} roughness={0.7} />
</mesh>
{/* Brass handle */}
<mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
<boxGeometry args={[0.04, 0.22, 0.025]} />
<meshStandardMaterial
color={brass}
roughness={0.22}
metalness={0.88}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.15 : 0}
/>
</mesh>
</group>
);
};
return (
<group position={position}>
<mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
{/* Wall reveal — depth into opening */}
<mesh position={[0, frameFullH / 2, 0.02]}>
<boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
<meshStandardMaterial color="#1a1410" roughness={0.95} />
</mesh>
{/* Warm vestibule glow beyond doors */}
<pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
<mesh position={[0, openingH * 0.55, 0.1]}>
<planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
<meshStandardMaterial
color={glow ? '#d4af37' : '#8b6914'}
emissive={glow ? '#5a4010' : '#000000'}
emissiveIntensity={glow ? 0.45 : 0}
roughness={0.55}
metalness={0.35}
color="#fff4e8"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.35 : 0.18}
roughness={0.9}
/>
</mesh>
{/* Large invisible target — easier to click from anywhere in the hall */}
<mesh
position={[0, DOOR_HEIGHT / 2, -0.12]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
{/* Marble threshold */}
<mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow>
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
</mesh>
<mesh position={[0, 0.052, faceZ + 0.05]}>
<boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
<meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
</mesh>
<Text
position={[0, DOOR_HEIGHT + 0.25, -0.06]}
{/* Door leaves */}
{doorLeaf('left')}
{doorLeaf('right')}
{/* Center meeting stile / push bar */}
<mesh position={[0, leafY, faceZ + 0.042]}>
<boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.2}
metalness={0.9}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.2 : 0}
/>
</mesh>
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow>
<boxGeometry args={[jamb, frameFullH, 0.1]} />
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
</mesh>
))}
{/* Header lintel */}
<mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow>
<boxGeometry args={[frameW, header, 0.1]} />
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
</mesh>
{/* Transom — frosted museum glass */}
<mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
<boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
<meshStandardMaterial
color="#f5f0e8"
emissive={highlight ? '#fff0d8' : '#ffeacc'}
emissiveIntensity={highlight ? 0.25 : 0.12}
roughness={0.15}
metalness={0.05}
transparent
opacity={0.88}
/>
</mesh>
{/* Transom mullions */}
{[-0.35, 0, 0.35].map((ox) => (
<mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
<boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.6} />
</mesh>
))}
{/* Limestone surround */}
{([-1, 1] as const).map((sign) => (
<mesh
key={`sur-${sign}`}
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
castShadow
>
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} />
</mesh>
))}
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
</mesh>
{/* Crown on surround */}
<mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
<boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
<meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
</mesh>
{/* Brass EXIT plaque */}
<group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
<mesh
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
<boxGeometry args={[0.55, 0.14, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.25}
metalness={0.85}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.25 : 0.05}
/>
</mesh>
<Text
position={[0, 0, -0.012]}
rotation={[0, Math.PI, 0]}
fontSize={0.09}
color={highlight ? '#fff8e8' : '#3d2818'}
anchorX="center"
anchorY="middle"
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
EXIT
</Text>
</group>
{/* Wall sconces */}
{([-1, 1] as const).map((sign) => (
<group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
<mesh>
<boxGeometry args={[0.08, 0.22, 0.1]} />
<meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
</mesh>
<mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.07, 0.12, 8, 1, true]} />
<meshStandardMaterial
color="#fff4e0"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.5 : 0.28}
roughness={0.4}
side={THREE.DoubleSide}
/>
</mesh>
<pointLight
position={[0, -0.12, -0.06]}
intensity={highlight ? 0.45 : 0.22}
distance={2.2}
color="#fff0d8"
/>
</group>
))}
{/* Hinges */}
{([-1, 1] as const).flatMap((side) =>
[0.35, 0.85, 1.35].map((hy) => (
<mesh
key={`hinge-${side}-${hy}`}
position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
rotation={[0, 0, side === -1 ? 0 : Math.PI]}
>
<boxGeometry args={[0.025, 0.1, 0.04]} />
<meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
</mesh>
))
)}
{/* Large invisible click target */}
<mesh
position={[0, frameFullH / 2, faceZ - 0.15]}
rotation={[0, Math.PI, 0]}
fontSize={0.22}
color={glow ? '#f5e6c8' : '#c9a96e'}
anchorX="center"
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
EXIT
</Text>
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
const floorW = width + 0.4;
const floorD = depth + 0.4;
const texture = useMemo(() => {
const map = createParquetFloorTexture();
map.repeat.set(floorW / PARQUET_METERS_PER_TILE, floorD / PARQUET_METERS_PER_TILE);
map.needsUpdate = true;
return map;
}, [floorW, floorD]);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
<planeGeometry args={[floorW, floorD]} />
<meshStandardMaterial
map={texture}
roughness={0.38}
metalness={0.12}
envMapIntensity={0.55}
color="#c8a882"
/>
</mesh>
);
}
function ArtistHall({
layout,
artistName,
movementColor,
imageRevisions,
onPaintingClick,
onExitActivate,
nearExit,
}: {
layout: HallLayout;
artistName: string;
movementColor?: string;
imageRevisions?: Record<number, number>;
onPaintingClick: (id: number) => void;
onExitActivate: () => void;
nearExit: boolean;
@@ -707,52 +1097,56 @@ function ArtistHall({
const { width, depth, segments } = layout;
const halfW = width / 2;
const halfD = depth / 2;
const walls = useMemo(() => galleryWallColors(movementColor), [movementColor]);
return (
<group>
{/* Floor & ceiling — open centre, no furniture */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
<meshStandardMaterial color="#4a3020" roughness={0.78} />
</mesh>
<ParquetFloor width={width} depth={depth} />
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
<meshStandardMaterial color="#2a2018" roughness={0.9} />
</mesh>
{/* Back wall */}
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color="#f0ebe3" />
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
{/* Left wall */}
<GalleryWall
position={[-halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT]}
rotation={[0, Math.PI / 2, 0]}
color="#e8e2d8"
color={walls.side}
/>
{/* Right wall */}
<GalleryWall
position={[halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT]}
rotation={[0, Math.PI / 2, 0]}
color="#e8e2d8"
color={walls.side}
/>
{/* Front wall — two segments with door gap */}
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color="#f0ebe3"
color={walls.main}
/>
<GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color="#f0ebe3"
color={walls.main}
/>
{/* Wall section above door opening */}
<GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main}
/>
{/* Crown molding on back wall */}
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
<boxGeometry args={[width, 0.14, 0.1]} />
<meshStandardMaterial color="#b8956a" roughness={0.5} metalness={0.25} />
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
</mesh>
<Text
@@ -768,13 +1162,14 @@ function ArtistHall({
<group key={`${seg.side}-${seg.label}`}>
{seg.paintings.map((painting, i) => (
<PaintingFrame
key={painting.id}
key={`${painting.id}-${imageRevisions?.[painting.id] ?? 0}`}
painting={painting}
position={seg.slots[i].position}
rotationY={seg.slots[i].rotationY}
maxWidth={seg.slots[i].maxW}
maxHeight={seg.slots[i].maxH}
wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]}
onClick={() => onPaintingClick(painting.id)}
/>
))}
@@ -800,10 +1195,15 @@ function ArtistHall({
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={nearExit}
onActivate={onExitActivate}
wallColor={walls.main}
trimColor={walls.trim}
/>
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={0.5} color="#fff5e8" distance={width + depth} />
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={12} />
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={Math.max(12, depth * 0.75)} />
{depth > 14 && (
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={0.3} color="#fff0dd" distance={14} />
)}
</group>
);
}
@@ -900,6 +1300,7 @@ function NavigationPanel({
export default function VirtualGallery({
data,
imageRevisions,
active = true,
onPaintingClick,
onNavigateArtist,
@@ -916,6 +1317,12 @@ export default function VirtualGallery({
const [nearExit, setNearExit] = useState(false);
const [isLooking, setIsLooking] = useState(false);
useEffect(() => {
setArtist(data.artist);
setPeriods(data.periods);
setPaintings(data.paintings);
}, [data]);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -946,16 +1353,17 @@ export default function VirtualGallery({
const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]);
const halfW = layout.width / 2 - 0.8;
const halfD = layout.depth / 2 - 0.8;
const exitZ = layout.depth / 2 - 0.6;
const halfW = layout.width / 2 - 0.55;
const halfD = layout.depth / 2 - 0.35;
const exitZ = layout.depth / 2 - 0.55;
const fogFar = Math.max(55, layout.depth + 42);
const initialPos = useMemo(
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
[layout.depth]
);
const initialTarget = useMemo(
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4),
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)),
[layout.depth]
);
@@ -1141,7 +1549,7 @@ export default function VirtualGallery({
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
>
<color attach="background" args={['#0d0906']} />
<fog attach="fog" args={['#0d0906', 18, 55]} />
<fog attach="fog" args={['#0d0906', 18, fogFar]} />
<ambientLight intensity={0.42} />
<directionalLight position={[3, 9, 4]} intensity={0.65} castShadow shadow-mapSize={[1024, 1024]} />
<Suspense fallback={null}>
@@ -1150,6 +1558,8 @@ export default function VirtualGallery({
<ArtistHall
layout={layout}
artistName={artist.name}
movementColor={artist.movement_color}
imageRevisions={imageRevisions}
onPaintingClick={onPaintingClick}
onExitActivate={openExitNav}
nearExit={nearExit}
+9 -2
View File
@@ -130,7 +130,10 @@ function SearchPreviewCell({
return (
<div className="checkup-thumb checkup-thumb-search" title={result.query}>
<img
src={debugImageProxyUrl(result.imageUrl)}
src={debugImageProxyUrl(result.imageUrl, {
searchUrl: result.searchUrl,
source: result.source,
})}
alt={`Search: ${result.query}`}
loading="lazy"
decoding="async"
@@ -337,7 +340,11 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
});
try {
const updated = await api.fixPaintingImage(row.id, searchState.result.imageUrl);
const updated = await api.fixPaintingImage(row.id, searchState.result.imageUrl, {
searchUrl: searchState.result.searchUrl,
source: searchState.result.source,
thumbUrl: searchState.result.thumbUrl,
});
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
setRows((list) =>
list.map((r) =>
+95 -32
View File
@@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import { api } from '../api/client';
import { api, type FixPaintingImageResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
@@ -18,6 +18,17 @@ type View =
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
patch: Partial<Painting>
): ArtistDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
@@ -31,6 +42,7 @@ export default function HomePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
@@ -88,42 +100,80 @@ export default function HomePage() {
});
};
const handlePaintingImageFixed = useCallback(async (paintingId: number) => {
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
setView((current) =>
current.type === 'painting' && current.paintingId === paintingId
? { ...current, data }
: current
);
const patch: Partial<Painting> = {
image_path: fixResult.imagePath ?? data.painting.image_path,
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
const updatedData: PaintingDetail = {
...data,
painting: { ...data.painting, ...patch },
};
setImageRevisions((prev) => ({ ...prev, [paintingId]: (prev[paintingId] ?? 0) + 1 }));
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) =>
p.id === paintingId
? { ...p, image_path: data.painting.image_path, thumbnail_path: data.painting.thumbnail_path }
: p
)
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
if (gallerySession?.artistId === data.painting.artist_id) {
setGallerySession((session) =>
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
}, []);
const handlePaintingCheckupFlagsUpdated = useCallback(
async (paintingId: number, flags: { checked: boolean; fixed: boolean }) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
checkup_checked: flags.checked,
checkup_fixed: flags.fixed,
};
const updatedData: PaintingDetail = {
...data,
painting: { ...data.painting, ...patch },
};
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) =>
session
? {
...session,
data: {
...session.data,
paintings: session.data.paintings.map((p) =>
p.id === paintingId
? {
...p,
image_path: data.painting.image_path,
thumbnail_path: data.painting.thumbnail_path,
}
: p
),
},
}
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
}
}, [gallerySession]);
},
[]
);
const handleArtistClick = async (artistId: number) => {
try {
@@ -210,6 +260,7 @@ export default function HomePage() {
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
<VirtualGallery
data={gallerySession.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
@@ -224,7 +275,18 @@ export default function HomePage() {
<PaintingDetailView
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
onBack={() => setView(view.returnTo)}
onBack={() => {
const returnTo = view.returnTo;
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
setView({
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
});
} else {
setView(returnTo);
}
}}
onPaintingClick={handlePaintingClick}
onCatalogNavigate={handleCatalogNavigate}
onArtistBio={async () => {
@@ -234,6 +296,7 @@ export default function HomePage() {
onInfluenceArtistClick={handleArtistClick}
debugMode={debugMode}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
/>
</div>
)}
+3 -1
View File
@@ -61,6 +61,8 @@ export interface Painting {
sort_order: number;
artist_name?: string;
has_influence_links?: boolean | string | number;
checkup_checked?: boolean;
checkup_fixed?: boolean;
}
export type InfluenceSourceType = 'painting' | 'artist' | 'movement';
@@ -99,7 +101,7 @@ export interface PaintingDetail {
}
export interface ArtistDetail {
artist: Artist & { movement_name: string };
artist: Artist & { movement_name: string; movement_color?: string };
periods: ArtistPeriod[];
paintings: Painting[];
}
+116
View File
@@ -0,0 +1,116 @@
import * as THREE from 'three';
/** Warm oak tones with subtle variation — museum-style herringbone parquet. */
const OAK_TONES = ['#6b4a2e', '#735234', '#624428', '#7a5638', '#5c3f26', '#6e4d30'];
function shade(hex: string, amount: number): string {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.max(0, ((n >> 16) & 255) + amount));
const g = Math.min(255, Math.max(0, ((n >> 8) & 255) + amount));
const b = Math.min(255, Math.max(0, (n & 255) + amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function drawPlank(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
length: number,
width: number,
angle: number,
color: string
) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
const grad = ctx.createLinearGradient(-length / 2, 0, length / 2, 0);
grad.addColorStop(0, shade(color, -12));
grad.addColorStop(0.35, color);
grad.addColorStop(0.65, shade(color, 6));
grad.addColorStop(1, shade(color, -8));
ctx.fillStyle = grad;
ctx.fillRect(-length / 2, -width / 2, length, width);
// Fine grain lines
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
ctx.lineWidth = 0.6;
for (let g = -length / 2 + 4; g < length / 2; g += 7) {
ctx.beginPath();
ctx.moveTo(g, -width / 2 + 1);
ctx.lineTo(g + 3, width / 2 - 1);
ctx.stroke();
}
// Plank edge / grout
ctx.strokeStyle = 'rgba(18,10,4,0.55)';
ctx.lineWidth = 1.1;
ctx.strokeRect(-length / 2, -width / 2, length, width);
ctx.restore();
}
function paintHerringbone(ctx: CanvasRenderingContext2D, size: number) {
ctx.fillStyle = '#2a1810';
ctx.fillRect(0, 0, size, size);
const plankLen = 56;
const plankW = 14;
const step = plankLen * 0.707; // cos(45°) spacing for herringbone unit
const cols = Math.ceil(size / step) + 3;
const rows = Math.ceil(size / step) + 3;
for (let row = -1; row < rows; row++) {
for (let col = -1; col < cols; col++) {
const px = col * step;
const py = row * step;
const tone = OAK_TONES[(row * 5 + col * 7 + size) % OAK_TONES.length];
if ((row + col) % 2 === 0) {
drawPlank(ctx, px, py, plankLen, plankW, Math.PI / 4, tone);
drawPlank(ctx, px + step * 0.5, py + step * 0.5, plankLen, plankW, Math.PI / 4, shade(tone, -4));
} else {
drawPlank(ctx, px + step * 0.25, py + step * 0.25, plankLen, plankW, -Math.PI / 4, tone);
drawPlank(ctx, px + step * 0.75, py + step * 0.75, plankLen, plankW, -Math.PI / 4, shade(tone, 5));
}
}
}
// Soft polish sheen
const sheen = ctx.createLinearGradient(0, 0, size, size);
sheen.addColorStop(0, 'rgba(255,240,210,0.06)');
sheen.addColorStop(0.5, 'rgba(255,255,255,0)');
sheen.addColorStop(1, 'rgba(255,230,200,0.04)');
ctx.fillStyle = sheen;
ctx.fillRect(0, 0, size, size);
}
let cachedTexture: THREE.CanvasTexture | null = null;
/** Seamless herringbone parquet — shared across gallery halls. */
export function createParquetFloorTexture(): THREE.CanvasTexture {
if (cachedTexture) return cachedTexture;
const size = 512;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create parquet canvas');
paintHerringbone(ctx, size);
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 8;
texture.needsUpdate = true;
cachedTexture = texture;
return texture;
}
/** Meters covered by one texture repeat — tune for plank scale in the hall. */
export const PARQUET_METERS_PER_TILE = 2.8;