Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5ddc3fd7f0
commit
bc8369e373
@@ -64,6 +64,14 @@ export async function loginCurator(username: string, password: string): Promise<
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? 'Login blocked by the reverse proxy (not the gallery). Use http://localhost:5173 or fix Keenetic access.'
|
||||
: `Login failed: HTTP ${res.status}`
|
||||
);
|
||||
}
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Login failed: ${res.status}`);
|
||||
}
|
||||
@@ -436,6 +444,20 @@ export const api = {
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
updatePaintingCuratorNotes: (id: number, curatorNotes: string) =>
|
||||
fetch(`${API}/paintings/${id}/curator-notes`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ curatorNotes }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ curatorNotes: string }>;
|
||||
}),
|
||||
|
||||
getArtistDebugPortraitSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
|
||||
|
||||
|
||||
@@ -441,6 +441,115 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.curator-notes-panel {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(201, 169, 110, 0.1);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||
}
|
||||
|
||||
.curator-notes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.curator-notes-panel h3 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.curator-notes-body {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: rgba(232, 213, 181, 0.92);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.curator-notes-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.curator-notes-edit-btn,
|
||||
.curator-notes-save-btn,
|
||||
.curator-notes-cancel-btn {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
background: transparent;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.curator-notes-edit-btn:hover,
|
||||
.curator-notes-cancel-btn:hover {
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.curator-notes-save-btn {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.curator-notes-save-btn:hover:not(:disabled) {
|
||||
background: rgba(201, 169, 110, 0.32);
|
||||
}
|
||||
|
||||
.curator-notes-edit-btn:disabled,
|
||||
.curator-notes-save-btn:disabled,
|
||||
.curator-notes-cancel-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.curator-notes-textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.55);
|
||||
color: rgba(232, 213, 181, 0.95);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.curator-notes-textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(201, 169, 110, 0.7);
|
||||
}
|
||||
|
||||
.curator-notes-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.curator-notes-error {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #e08a6a;
|
||||
}
|
||||
|
||||
.painting-description {
|
||||
max-width: 700px;
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
onCatalogNavigate: (paintingId: number) => void;
|
||||
onArtistBio: () => void;
|
||||
onInfluenceArtistClick?: (artistId: number) => void;
|
||||
isCurator?: boolean;
|
||||
debugMode?: boolean;
|
||||
debugShowMore?: boolean;
|
||||
onPaintingImageFixed?: (
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
flags: { checked: boolean; fixed: boolean }
|
||||
) => void | Promise<void>;
|
||||
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
|
||||
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
|
||||
}
|
||||
|
||||
function influenceKey(inf: InfluenceLink, index: number): string {
|
||||
@@ -203,15 +205,22 @@ export default function PaintingDetailView({
|
||||
onCatalogNavigate,
|
||||
onArtistBio,
|
||||
onInfluenceArtistClick,
|
||||
isCurator = false,
|
||||
debugMode = false,
|
||||
debugShowMore = false,
|
||||
onPaintingImageFixed,
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
onPaintingRemoved,
|
||||
onCuratorNotesUpdated,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('painting');
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
const inTour = tourText != null;
|
||||
const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? '');
|
||||
const [editingNotes, setEditingNotes] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? '');
|
||||
const [savingNotes, setSavingNotes] = useState(false);
|
||||
const [notesError, setNotesError] = useState<string | null>(null);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [imageVersion, setImageVersion] = useState(0);
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
@@ -257,7 +266,13 @@ export default function PaintingDetailView({
|
||||
setMarkingChecked(false);
|
||||
setApplyingUrl(null);
|
||||
setMoreLoading(false);
|
||||
}, [painting.id]);
|
||||
const notes = painting.curator_notes ?? '';
|
||||
setCuratorNotes(notes);
|
||||
setNotesDraft(notes);
|
||||
setEditingNotes(false);
|
||||
setSavingNotes(false);
|
||||
setNotesError(null);
|
||||
}, [painting.id, painting.curator_notes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debugMode) {
|
||||
@@ -291,6 +306,34 @@ export default function PaintingDetailView({
|
||||
};
|
||||
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
|
||||
|
||||
const startEditingNotes = () => {
|
||||
setNotesDraft(curatorNotes);
|
||||
setNotesError(null);
|
||||
setEditingNotes(true);
|
||||
};
|
||||
|
||||
const cancelEditingNotes = () => {
|
||||
setNotesDraft(curatorNotes);
|
||||
setNotesError(null);
|
||||
setEditingNotes(false);
|
||||
};
|
||||
|
||||
const saveCuratorNotes = async () => {
|
||||
setSavingNotes(true);
|
||||
setNotesError(null);
|
||||
try {
|
||||
const result = await api.updatePaintingCuratorNotes(painting.id, notesDraft);
|
||||
setCuratorNotes(result.curatorNotes);
|
||||
setNotesDraft(result.curatorNotes);
|
||||
setEditingNotes(false);
|
||||
onCuratorNotesUpdated?.(painting.id, result.curatorNotes);
|
||||
} catch {
|
||||
setNotesError(t('curatorNotesSaveFailed'));
|
||||
} finally {
|
||||
setSavingNotes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
|
||||
setImageVersion((v) => v + 1);
|
||||
if (onPaintingImageFixed) {
|
||||
@@ -586,6 +629,57 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{(isCurator || curatorNotes.trim()) && (
|
||||
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
|
||||
<div className="curator-notes-header">
|
||||
<h3>{t('curatorNotes')}</h3>
|
||||
{isCurator && !editingNotes && (
|
||||
<button
|
||||
type="button"
|
||||
className="curator-notes-edit-btn"
|
||||
onClick={startEditingNotes}
|
||||
>
|
||||
{t('curatorNotesEdit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editingNotes ? (
|
||||
<div className="curator-notes-editor">
|
||||
<textarea
|
||||
className="curator-notes-textarea"
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
rows={6}
|
||||
disabled={savingNotes}
|
||||
aria-label={t('curatorNotes')}
|
||||
/>
|
||||
{notesError && <p className="curator-notes-error">{notesError}</p>}
|
||||
<div className="curator-notes-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="curator-notes-save-btn"
|
||||
onClick={saveCuratorNotes}
|
||||
disabled={savingNotes}
|
||||
>
|
||||
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="curator-notes-cancel-btn"
|
||||
onClick={cancelEditingNotes}
|
||||
disabled={savingNotes}
|
||||
>
|
||||
{t('curatorNotesCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : curatorNotes.trim() ? (
|
||||
<p className="curator-notes-body">{curatorNotes}</p>
|
||||
) : (
|
||||
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{painting.description && (
|
||||
<div className="painting-description">
|
||||
<p>{painting.description}</p>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||
@@ -294,17 +294,26 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit order along the side walls: first half on the left (starting at the
|
||||
* entrance, left of the opening view), second half on the right (ending at the
|
||||
* entrance). Back wall stays empty so first/last always sit on left/right.
|
||||
* U-shaped visit order:
|
||||
* left wall (first, near entrance) → far/end wall → right wall (last, near entrance).
|
||||
* Fewer than 3 works keep the old left/right split so first stays left and last stays right.
|
||||
* The far wall is the solid wall ahead when entering (code side `'back'`).
|
||||
*/
|
||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||
const ordered = [...paintings].sort(comparePaintingsChronological);
|
||||
const mid = Math.ceil(ordered.length / 2);
|
||||
const n = ordered.length;
|
||||
if (n < 3) {
|
||||
const mid = Math.ceil(n / 2);
|
||||
return [[] as Painting[], ordered.slice(0, mid), ordered.slice(mid)];
|
||||
}
|
||||
const q = Math.floor(n / 3);
|
||||
const r = n % 3;
|
||||
const leftCount = q + (r > 0 ? 1 : 0);
|
||||
const backCount = q + (r > 1 ? 1 : 0);
|
||||
return [
|
||||
[] as Painting[],
|
||||
ordered.slice(0, mid),
|
||||
ordered.slice(mid),
|
||||
ordered.slice(leftCount, leftCount + backCount),
|
||||
ordered.slice(0, leftCount),
|
||||
ordered.slice(leftCount + backCount),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -390,18 +399,19 @@ function layoutWallSlots(
|
||||
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
|
||||
const walls: WallSide[] = ['back', 'left', 'right'];
|
||||
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
||||
const [backPaintings, leftPaintings, rightPaintings] = wallPaintings;
|
||||
|
||||
let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
|
||||
let width = minSpanForWall(backPaintings, MAX_WALL_ROWS);
|
||||
let depth = Math.max(
|
||||
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||||
);
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
|
||||
const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS);
|
||||
const nextDepth = Math.max(
|
||||
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||||
);
|
||||
if (nextWidth === width && nextDepth === depth) break;
|
||||
width = nextWidth;
|
||||
@@ -694,6 +704,61 @@ function InfluencePictureLamp({
|
||||
);
|
||||
}
|
||||
|
||||
function CuratorNotesPlate({
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
matBorder,
|
||||
rail,
|
||||
frameDepth,
|
||||
faceZ,
|
||||
highlighted,
|
||||
}: {
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
matBorder: number;
|
||||
rail: number;
|
||||
frameDepth: number;
|
||||
faceZ: number;
|
||||
highlighted: boolean;
|
||||
}) {
|
||||
const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42));
|
||||
const plateH = 0.038;
|
||||
const plateD = 0.012;
|
||||
const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028;
|
||||
const z = frameDepth + faceZ + 0.01;
|
||||
const brass = highlighted ? '#e8c76a' : '#d4af37';
|
||||
const rim = highlighted ? '#a07828' : '#8a6820';
|
||||
|
||||
return (
|
||||
<group position={[0, y, z]}>
|
||||
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
|
||||
<mesh position={[0, 0, -0.001]} castShadow renderOrder={28}>
|
||||
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
|
||||
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
|
||||
</mesh>
|
||||
<mesh castShadow renderOrder={29}>
|
||||
<boxGeometry args={[plateW, plateH, plateD]} />
|
||||
<meshStandardMaterial
|
||||
color={brass}
|
||||
metalness={0.9}
|
||||
roughness={0.22}
|
||||
emissive={highlighted ? '#6a5010' : '#3a2a08'}
|
||||
emissiveIntensity={highlighted ? 0.35 : 0.12}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Soft engraved center band */}
|
||||
<mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
|
||||
<planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
|
||||
<meshStandardMaterial
|
||||
color={highlighted ? '#b8943a' : '#9a7828'}
|
||||
metalness={0.7}
|
||||
roughness={0.4}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PaintingFrame({
|
||||
painting,
|
||||
position,
|
||||
@@ -726,6 +791,7 @@ function PaintingFrame({
|
||||
const showImage = hasImage && !failed && !!texture;
|
||||
const showCanvas = !showImage;
|
||||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||||
const hasCuratorNotes = paintingHasCuratorNotes(painting);
|
||||
const finish = frameFinish(reviewed, hovered);
|
||||
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
||||
|
||||
@@ -737,6 +803,9 @@ function PaintingFrame({
|
||||
}
|
||||
}, [texture, showImage]);
|
||||
|
||||
const captionY =
|
||||
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
|
||||
|
||||
return (
|
||||
<group position={position} rotation={[0, rotationY, 0]}>
|
||||
<spotLight
|
||||
@@ -812,9 +881,21 @@ function PaintingFrame({
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasCuratorNotes && (
|
||||
<CuratorNotesPlate
|
||||
frameWidth={width}
|
||||
frameHeight={height}
|
||||
matBorder={matBorder}
|
||||
rail={rail}
|
||||
frameDepth={frameDepth}
|
||||
faceZ={faceZ}
|
||||
highlighted={hovered}
|
||||
/>
|
||||
)}
|
||||
|
||||
{caption && (
|
||||
<Text
|
||||
position={[0, -height / 2 - matBorder - rail - 0.1, frameDepth + faceZ + 0.02]}
|
||||
position={[0, captionY, frameDepth + faceZ + 0.02]}
|
||||
fontSize={0.085}
|
||||
maxWidth={Math.max(width + matBorder * 2, 0.55)}
|
||||
color="#4a3828"
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"tourNotes": "Tour notes",
|
||||
"tourNotesFor": "Tour notes · {{title}}",
|
||||
"tourNotesEmpty": "No notes for this stop.",
|
||||
"tourStopPosition": "Stop {{current}} of {{total}}"
|
||||
"tourStopPosition": "Stop {{current}} of {{total}}",
|
||||
"curatorNotes": "Curator notes",
|
||||
"curatorNotesEmpty": "No curator notes yet.",
|
||||
"curatorNotesEdit": "Edit",
|
||||
"curatorNotesSave": "Save",
|
||||
"curatorNotesSaving": "Saving…",
|
||||
"curatorNotesCancel": "Cancel",
|
||||
"curatorNotesSaveFailed": "Could not save curator notes."
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"tourNotes": "Текст экскурсии",
|
||||
"tourNotesFor": "Экскурсия · {{title}}",
|
||||
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}"
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}",
|
||||
"curatorNotes": "Заметки куратора",
|
||||
"curatorNotesEmpty": "Заметок куратора пока нет.",
|
||||
"curatorNotesEdit": "Изменить",
|
||||
"curatorNotesSave": "Сохранить",
|
||||
"curatorNotesSaving": "Сохранение…",
|
||||
"curatorNotesCancel": "Отмена",
|
||||
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
|
||||
}
|
||||
|
||||
@@ -444,6 +444,58 @@ export default function HomePage() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCuratorNotesUpdated = useCallback((paintingId: number, curatorNotes: string) => {
|
||||
const patch: Partial<Painting> = { curator_notes: curatorNotes };
|
||||
|
||||
setView((current) => {
|
||||
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
|
||||
let returnTo = current.returnTo;
|
||||
if (returnTo.type === 'gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'tour-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
data: {
|
||||
...current.data,
|
||||
painting: { ...current.data.painting, ...patch },
|
||||
},
|
||||
returnTo,
|
||||
};
|
||||
});
|
||||
|
||||
setDetailArtistPaintings((list) =>
|
||||
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
|
||||
);
|
||||
|
||||
setGallerySession((session) => {
|
||||
if (session?.kind === 'artist') {
|
||||
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'tour') {
|
||||
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
|
||||
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
|
||||
|
||||
@@ -900,11 +952,13 @@ export default function HomePage() {
|
||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
isCurator={isCurator}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
onCuratorNotesUpdated={handleCuratorNotesUpdated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Painting {
|
||||
year: number;
|
||||
year_end?: number;
|
||||
description: string;
|
||||
curator_notes?: string;
|
||||
image_path: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
|
||||
@@ -43,6 +43,9 @@ const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 10;
|
||||
const MIN_HALL_WIDTH = 11;
|
||||
const WALL_PADDING = 1.4;
|
||||
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
|
||||
const DOOR_WIDTH = 2.4;
|
||||
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
|
||||
|
||||
const FRAME_MAT_BORDER = 0.1;
|
||||
const FRAME_RAIL = 0.08;
|
||||
@@ -110,15 +113,28 @@ export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting
|
||||
}
|
||||
|
||||
/**
|
||||
* First half → left wall, second half → right.
|
||||
* U-shaped visit order: left → far/end wall → right.
|
||||
* Callers pass paintings already in visit order; first work hangs near the
|
||||
* entrance on the left, last work near the entrance on the right.
|
||||
*/
|
||||
function distributeToSideWalls(paintings: Painting[]) {
|
||||
const mid = Math.ceil(paintings.length / 2);
|
||||
function distributeAcrossWalls(paintings: Painting[]) {
|
||||
const n = paintings.length;
|
||||
if (n < 3) {
|
||||
const mid = Math.ceil(n / 2);
|
||||
return {
|
||||
back: [] as Painting[],
|
||||
left: paintings.slice(0, mid),
|
||||
right: paintings.slice(mid),
|
||||
};
|
||||
}
|
||||
const q = Math.floor(n / 3);
|
||||
const r = n % 3;
|
||||
const leftCount = q + (r > 0 ? 1 : 0);
|
||||
const backCount = q + (r > 1 ? 1 : 0);
|
||||
return {
|
||||
left: paintings.slice(0, mid),
|
||||
right: paintings.slice(mid),
|
||||
left: paintings.slice(0, leftCount),
|
||||
back: paintings.slice(leftCount, leftCount + backCount),
|
||||
right: paintings.slice(leftCount + backCount),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,21 +165,59 @@ function layoutSideSlots(
|
||||
});
|
||||
}
|
||||
|
||||
/** Far wall ahead of the entrance — split across door flanks (exit sits in the center). */
|
||||
function layoutBackSlots(
|
||||
paintings: Painting[],
|
||||
width: number,
|
||||
halfD: number,
|
||||
inset: number
|
||||
): FrameSlot[] {
|
||||
if (paintings.length === 0) return [];
|
||||
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
|
||||
const mid = Math.ceil(paintings.length / 2);
|
||||
const leftFlank = paintings.slice(0, mid);
|
||||
const rightFlank = paintings.slice(mid);
|
||||
const y = EYE_HEIGHT;
|
||||
const z = -halfD + inset + WALL_STANDOFF;
|
||||
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
|
||||
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
|
||||
|
||||
const mapFlank = (group: Painting[], centerX: number): FrameSlot[] => {
|
||||
if (group.length === 0) return [];
|
||||
const { slots: rowSlots } = layoutRow(group, flankSpan);
|
||||
return rowSlots.map((s) => ({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: 0,
|
||||
side: 'back' as const,
|
||||
position: [centerX + s.offset, y, z] as [number, number, number],
|
||||
}));
|
||||
};
|
||||
|
||||
return [...mapFlank(leftFlank, leftCenterX), ...mapFlank(rightFlank, rightCenterX)];
|
||||
}
|
||||
|
||||
export function buildMovementHallLayout(
|
||||
paintings: Painting[],
|
||||
hallIndex: number,
|
||||
hallCount: number
|
||||
): MovementHallLayout {
|
||||
const { left, right } = distributeToSideWalls(paintings);
|
||||
const { left, back, right } = distributeAcrossWalls(paintings);
|
||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||
const width = MIN_HALL_WIDTH;
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||
|
||||
const segments: WallSegment[] = [
|
||||
{ side: 'back', label: '', paintings: [], slots: [] },
|
||||
{
|
||||
side: 'back',
|
||||
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
||||
paintings: back,
|
||||
slots: layoutBackSlots(back, width, halfD, inset),
|
||||
},
|
||||
{
|
||||
side: 'left',
|
||||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||||
|
||||
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
|
||||
const flag = painting.has_influence_links;
|
||||
return flag === true || flag === 't' || flag === 'true' || flag === 1;
|
||||
}
|
||||
|
||||
/** True when the painting has public curator notes. */
|
||||
export function paintingHasCuratorNotes(
|
||||
painting: Pick<Painting, 'curator_notes'>
|
||||
): boolean {
|
||||
return Boolean(painting.curator_notes?.trim());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user