'use client'; import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, horizontalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import AlbumPicker from './AlbumPicker'; import AssetPicker from './AssetPicker'; import AssetOrderEditor from './AssetOrderEditor'; import { EssayBlockEditor } from './EssayBlockEditor'; import SaveBar from './SaveBar'; import { useScrollLock } from './useScrollLock'; import { ALBUM_SORT_MODES, DEFAULT_ALBUM_SORT, isAlbumSortMode, type AlbumSortMode, } from '@/lib/albumSort'; import type { AlbumEntryObject } from '@/lib/config/schema'; import { IconArrowDown, IconArrowUp, IconBook, IconCalendar, IconCamera, IconCopy, IconFolder, IconGripVertical, IconHome, IconImage, IconLock, IconPencil, IconPlus, IconSearch, IconSortAlpha, IconTrash, IconX, } from './Icons'; import { Listbox, type ListboxOption } from './Listbox'; /** * Labels for the per-album sort control. * * Kept as plain text on their own because they also feed the `title` attribute * on the album card's sort badge, where markup is not an option. */ const SORT_LABELS: Record = { immich: 'Immich order (default)', newest: 'Newest first', oldest: 'Oldest first', filename: 'Filename', manual: 'Manual', }; /** Manual reuses the drag glyph so the control matches the card's badge. */ const SORT_ICONS: Record = { immich: , newest: , oldest: , filename: , manual: , }; const SORT_OPTIONS: readonly ListboxOption[] = ALBUM_SORT_MODES.map((mode) => ({ value: mode, label: SORT_LABELS[mode], icon: SORT_ICONS[mode], })); // ── Types ────────────────────────────────────────────────────── interface AlbumEntry { id: string; title?: string; description?: string; password?: string; heroImage?: string; sort?: AlbumSortMode; /** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */ assetOrder?: string[]; } interface Section { title: string; description?: string; albums: AlbumEntry[]; } interface Subpage { name: string; title?: string; subtitle?: string; password?: string; enabled?: boolean; essayText?: string; essayFile?: string; sections?: Section[]; albums: AlbumEntry[]; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; } interface GalleryState { hero: string[]; albums: AlbumEntry[]; subpages: Subpage[]; } interface ActiveEditAlbumAddress { type: 'standalone' | 'subpage' | 'section'; subpageIndex?: number; sectionIndex?: number; albumIndex: number; } interface ImmichAlbumInfo { id: string; albumName: string; description: string; thumbnailAssetId: string | null; assetCount: number; isConfigured: boolean; } // ── Helpers ──────────────────────────────────────────────────── type RawAlbumEntry = string | Record; /** * Whether an entry carries anything beyond its ID. * * Both collapse rules below depend on this, and they are the reason every new * per-album option has to be listed here: an entry that looks "empty" is * serialized back to a bare UUID string, so a field missing from this check is * silently dropped on the next save. */ function hasAlbumOptions(entry: AlbumEntry): boolean { return Boolean( entry.description || entry.password || entry.heroImage || (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) || entry.assetOrder?.length, ); } function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] { if (!raw) return []; return raw.map((entry) => { if (typeof entry === 'string') return { id: entry }; const [id, value] = Object.entries(entry)[0]; if (typeof value === 'string') return { id, title: value }; return { id, title: value.title, description: value.description, password: value.password, heroImage: value.heroImage, sort: isAlbumSortMode(value.sort) ? value.sort : undefined, assetOrder: value.assetOrder, }; }); } function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] { return entries.map((entry) => { const extras = hasAlbumOptions(entry); if (!entry.title && !extras) return entry.id; if (entry.title && !extras) return { [entry.id]: entry.title }; const val: AlbumEntryObject = {}; // Only when set: a sort-only entry would otherwise be written with an empty // title, which deriveGallery ignores but which still lands in the YAML. if (entry.title) val.title = entry.title; if (entry.description) val.description = entry.description; if (entry.password) val.password = entry.password; if (entry.heroImage) val.heroImage = entry.heroImage; if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort; // Persisted regardless of the mode, so manual → newest → manual does not // throw away a hand-curated order. if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder; return { [entry.id]: val }; }); } // ── Sortable Hero Tile ───────────────────────────────────────── function SortableHeroTile({ id, index, onRemove, }: { id: string; index: number; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `hero-${index}`, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : undefined, }; return (
{index + 1}
); } // ── Sortable Album Card ──────────────────────────────────────── function SortableAlbumCard({ album, index, name, count, thumbnailId, onRemove, onEdit, }: { album: AlbumEntry; index: number; name: string; count: number; thumbnailId: string | null; onRemove: () => void; onEdit: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `album-${album.id}-${index}`, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : undefined, }; return (
); } // ── Sortable Subpage Tile ────────────────────────────────────── function SortableSubpageTile({ sp, spIndex, isActive, onClick, getFirstThumb, }: { sp: Subpage; spIndex: number; isActive: boolean; onClick: () => void; getFirstThumb: (sp: Subpage) => string | null; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `subpage-${spIndex}`, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : undefined, }; const totalAlbums = sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); const firstThumb = getFirstThumb(sp); const slug = sp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); return (
{sp.enabled === false && ( Disabled )} {sp.password && ( Password )}
{firstThumb ? ( ) : (
)}
Edit Page
{sp.title || sp.name} /{slug}
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
); } // ── Component ────────────────────────────────────────────────── export default function PageBuilder() { const [gallery, setGallery] = useState({ hero: [], albums: [], subpages: [] }); const [immichAlbums, setImmichAlbums] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [saveMessage, setSaveMessage] = useState(''); const [expandedSubpage, setExpandedSubpage] = useState(null); const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit'); const [pickerTarget, setPickerTarget] = useState<{ type: 'standalone' | 'subpage' | 'section'; subpageIndex?: number; sectionIndex?: number; } | null>(null); const [heroPickerTarget, setHeroPickerTarget] = useState<{ albumId?: string; onSelect: (assetId: string) => void; currentAssetIds?: string[]; title?: string; } | null>(null); const [orderEditorTarget, setOrderEditorTarget] = useState<{ albumId: string; albumName: string; assetOrder: string[]; onSave: (assetOrder: string[]) => void; } | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [editingAlbumAddress, setEditingAlbumAddress] = useState(null); // Keep the builder still behind either drawer. One combined lock rather than // one per drawer: the album drawer opens from inside the subpage drawer, and // a single condition avoids two locks racing over the same inline style. useScrollLock(editingAlbumAddress !== null || expandedSubpage !== null); // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Load data useEffect(() => { loadData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (dirty && !saving) { handleSave(); } } } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dirty, saving, gallery]); // ── Unsaved changes guard ──────────────────────────────────── useEffect(() => { function handleBeforeUnload(e: BeforeUnloadEvent) { if (dirty) { e.preventDefault(); e.returnValue = ''; } } window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [dirty]); async function loadData() { setLoading(true); try { const [galleryRes, albumsRes] = await Promise.all([ fetch('/api/admin/gallery'), fetch('/api/admin/albums'), ]); if (galleryRes.ok) { const { gallery: raw } = await galleryRes.json(); setGallery(parseGalleryYaml(raw)); } if (albumsRes.ok) { const { albums } = await albumsRes.json(); setImmichAlbums(albums); } } catch (err) { console.error('Failed to load admin data:', err); } finally { setLoading(false); } } function parseGalleryYaml(raw: Record): GalleryState { const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : []; const albums = parseAlbumEntries( raw.albums as Array> | undefined, ); let subpages: Subpage[] = []; if (Array.isArray(raw.subpages)) { subpages = (raw.subpages as Array>).map((sp) => ({ name: (sp.name as string) || '', title: sp.title as string | undefined, subtitle: sp.subtitle as string | undefined, password: sp.password as string | undefined, enabled: sp.enabled !== false, essayText: sp.essayText as string | undefined, essayFile: sp.essayFile as string | undefined, albums: parseAlbumEntries(sp.albums as Array> | undefined), sections: sp.sections ? (sp.sections as Array>).map((sec) => ({ title: (sec.title as string) || '', description: sec.description as string | undefined, albums: parseAlbumEntries(sec.albums as Array>), })) : undefined, grid: sp.grid as Subpage['grid'], })); } else if (raw.subpages && typeof raw.subpages === 'object') { subpages = Object.entries(raw.subpages as Record).map(([name, value]) => { if (Array.isArray(value)) { return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true }; } const sp = value as Record; return { name, title: sp.title as string | undefined, subtitle: sp.subtitle as string | undefined, password: sp.password as string | undefined, enabled: sp.enabled !== false, essayText: sp.essayText as string | undefined, essayFile: sp.essayFile as string | undefined, albums: parseAlbumEntries( sp.albums as Array> | undefined, ), sections: undefined, grid: sp.grid as Subpage['grid'], }; }); } return { hero, albums, subpages }; } const markDirty = useCallback(() => { setDirty(true); setSaveMessage(''); }, []); // ── Save ────────────────────────────────────────────────────── async function handleSave() { setSaving(true); setSaveMessage(''); const yamlData: Record = {}; if (gallery.hero.length > 0) { yamlData.hero = gallery.hero; } if (gallery.albums.length > 0) { yamlData.albums = serializeAlbumEntries(gallery.albums); } if (gallery.subpages.length > 0) { yamlData.subpages = gallery.subpages.map((sp) => { const entry: Record = { name: sp.name }; if (sp.title) entry.title = sp.title; if (sp.subtitle) entry.subtitle = sp.subtitle; if (sp.password) entry.password = sp.password; if (sp.enabled === false) entry.enabled = false; if (sp.essayText) entry.essayText = sp.essayText; if (sp.essayFile) entry.essayFile = sp.essayFile; if (sp.grid) entry.grid = sp.grid; if (sp.sections && sp.sections.length > 0) { entry.sections = sp.sections.map((sec) => { const s: Record = { title: sec.title, albums: serializeAlbumEntries(sec.albums), }; if (sec.description) s.description = sec.description; return s; }); if (sp.albums.length > 0) { entry.albums = serializeAlbumEntries(sp.albums); } } else { entry.albums = serializeAlbumEntries(sp.albums); } return entry; }); } try { const res = await fetch('/api/admin/gallery', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gallery: yamlData }), }); if (res.ok) { const data = await res.json(); setDirty(false); setSaveMessage(data.message || 'Saved successfully!'); setTimeout(() => setSaveMessage(''), 5000); } else { const err = await res.json(); setSaveMessage(`Error: ${err.error}`); } } catch { setSaveMessage('Error: Failed to save'); } finally { setSaving(false); } } // ── Hero Picker ────────────────────────────────────────────── function handleHeroSelect(assetId: string) { setGallery((g) => ({ ...g, hero: [...g.hero, assetId] })); setHeroPickerTarget(null); markDirty(); } // ── Album Picker Handlers ──────────────────────────────────── function handlePickAlbum(albumId: string) { if (!pickerTarget) return; const entry: AlbumEntry = { id: albumId }; if (pickerTarget.type === 'standalone') { setGallery((g) => ({ ...g, albums: [...g.albums, entry] })); } else if (pickerTarget.type === 'subpage' && pickerTarget.subpageIndex != null) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[pickerTarget.subpageIndex!] }; sp.albums = [...sp.albums, entry]; subpages[pickerTarget.subpageIndex!] = sp; return { ...g, subpages }; }); } else if ( pickerTarget.type === 'section' && pickerTarget.subpageIndex != null && pickerTarget.sectionIndex != null ) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[pickerTarget.subpageIndex!] }; const sections = [...(sp.sections || [])]; const sec = { ...sections[pickerTarget.sectionIndex!] }; sec.albums = [...sec.albums, entry]; sections[pickerTarget.sectionIndex!] = sec; sp.sections = sections; subpages[pickerTarget.subpageIndex!] = sp; return { ...g, subpages }; }); } setPickerTarget(null); markDirty(); } // ── Drag & Drop Handlers ───────────────────────────────────── function handleHeroDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === active.id); const newIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { setGallery((g) => ({ ...g, hero: arrayMove(g.hero, oldIndex, newIndex) })); markDirty(); } } function handleAlbumDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id); const newIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { setGallery((g) => ({ ...g, albums: arrayMove(g.albums, oldIndex, newIndex) })); markDirty(); } } function handleSubpageDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id); const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { setGallery((g) => ({ ...g, subpages: arrayMove(g.subpages, oldIndex, newIndex) })); if (expandedSubpage === oldIndex) setExpandedSubpage(newIndex); else if (expandedSubpage !== null) { if (oldIndex < expandedSubpage && newIndex >= expandedSubpage) setExpandedSubpage(expandedSubpage - 1); else if (oldIndex > expandedSubpage && newIndex <= expandedSubpage) setExpandedSubpage(expandedSubpage + 1); } markDirty(); } } function handleSubpageAlbumDragEnd(spIndex: number) { return (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const sp = gallery.subpages[spIndex]; const oldIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id); const newIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { setGallery((g) => { const subpages = [...g.subpages]; const sp2 = { ...subpages[spIndex] }; sp2.albums = arrayMove(sp2.albums, oldIndex, newIndex); subpages[spIndex] = sp2; return { ...g, subpages }; }); markDirty(); } }; } // ── Subpage Management ──────────────────────────────────────── function addSubpage() { setGallery((g) => ({ ...g, subpages: [ ...g.subpages, { name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined }, ], })); markDirty(); } function removeSubpage(index: number) { if (!confirm('Remove this subpage?')) return; setGallery((g) => ({ ...g, subpages: g.subpages.filter((_, i) => i !== index), })); markDirty(); } function updateSubpage(index: number, updates: Partial) { setGallery((g) => { const subpages = [...g.subpages]; subpages[index] = { ...subpages[index], ...updates }; return { ...g, subpages }; }); markDirty(); } // ── Section Management ──────────────────────────────────────── function addSection(subpageIndex: number) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[subpageIndex] }; sp.sections = [...(sp.sections || []), { title: 'New Section', albums: [] }]; subpages[subpageIndex] = sp; return { ...g, subpages }; }); markDirty(); } function removeSection(subpageIndex: number, sectionIndex: number) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[subpageIndex] }; sp.sections = (sp.sections || []).filter((_, i) => i !== sectionIndex); subpages[subpageIndex] = sp; return { ...g, subpages }; }); markDirty(); } function updateSection(subpageIndex: number, sectionIndex: number, updates: Partial
) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[subpageIndex] }; const sections = [...(sp.sections || [])]; sections[sectionIndex] = { ...sections[sectionIndex], ...updates }; sp.sections = sections; subpages[subpageIndex] = sp; return { ...g, subpages }; }); markDirty(); } // ── Album Removal ──────────────────────────────────────────── function removeStandaloneAlbum(index: number) { setGallery((g) => ({ ...g, albums: g.albums.filter((_, i) => i !== index), })); markDirty(); } function removeSubpageAlbum(subpageIndex: number, albumIndex: number) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[subpageIndex] }; sp.albums = sp.albums.filter((_, i) => i !== albumIndex); subpages[subpageIndex] = sp; return { ...g, subpages }; }); markDirty(); } function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) { setGallery((g) => { const subpages = [...g.subpages]; const sp = { ...subpages[subpageIndex] }; const sections = [...(sp.sections || [])]; const sec = { ...sections[sectionIndex] }; sec.albums = sec.albums.filter((_, i) => i !== albumIndex); sections[sectionIndex] = sec; sp.sections = sections; subpages[subpageIndex] = sp; return { ...g, subpages }; }); markDirty(); } // ── Hero Management ────────────────────────────────────────── function removeHero(index: number) { setGallery((g) => ({ ...g, hero: g.hero.filter((_, i) => i !== index), })); markDirty(); } // ── Helpers ────────────────────────────────────────────────── function getAlbumName(id: string): string { const found = immichAlbums.find((a) => a.id === id); return found?.albumName || id.slice(0, 8) + '...'; } function getAlbumCount(id: string): number { const found = immichAlbums.find((a) => a.id === id); return found?.assetCount || 0; } function getAlbumThumbnailId(id: string): string | null { const found = immichAlbums.find((a) => a.id === id); return found?.thumbnailAssetId || null; } function getFirstSubpageThumb(sp: Subpage): string | null { for (const album of sp.albums) { const thumb = getAlbumThumbnailId(album.id); if (thumb) return thumb; } if (sp.sections) { for (const sec of sp.sections) { for (const album of sec.albums) { const thumb = getAlbumThumbnailId(album.id); if (thumb) return thumb; } } } return null; } function slugify(str: string): string { return str .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } // ── Render ─────────────────────────────────────────────────── if (loading) { return (
); } // Filter standalone albums const filteredAlbums = gallery.albums.filter((album) => { if (!searchQuery) return true; const name = getAlbumName(album.id).toLowerCase(); const description = (album.description || '').toLowerCase(); const overrideTitle = (album.title || '').toLowerCase(); const query = searchQuery.toLowerCase(); return name.includes(query) || description.includes(query) || overrideTitle.includes(query) || album.id.toLowerCase().includes(query); }); // Filter subpages const filteredSubpages = gallery.subpages .map((sp, index) => ({ sp, index })) .filter(({ sp }) => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); const name = sp.name.toLowerCase(); const title = (sp.title || '').toLowerCase(); const subtitle = (sp.subtitle || '').toLowerCase(); if (name.includes(query) || title.includes(query) || subtitle.includes(query)) return true; const albums = sp.albums || []; const hasMatchingAlbum = albums.some((a) => { const aName = getAlbumName(a.id).toLowerCase(); const aTitle = (a.title || '').toLowerCase(); const aDesc = (a.description || '').toLowerCase(); return aName.includes(query) || aTitle.includes(query) || aDesc.includes(query) || a.id.toLowerCase().includes(query); }); return hasMatchingAlbum; }); return (
{/* Search Bar */}
setSearchQuery(e.target.value)} /> {searchQuery && ( )}
{/* Hero Section */}

Homepage Hero

`hero-${i}`)} strategy={horizontalListSortingStrategy} >
{gallery.hero.length === 0 && (

No hero images configured. Add photos to show a hero carousel on the homepage.

)} {gallery.hero.map((id, i) => ( removeHero(i)} /> ))}
{/* Standalone Albums */}

Standalone Albums

{ const originalIndex = gallery.albums.findIndex((x) => x.id === a.id); return `album-${a.id}-${originalIndex}`; })} strategy={verticalListSortingStrategy} >
{filteredAlbums.length === 0 && (

{searchQuery ? 'No matching standalone albums found.' : 'No standalone albums. These show directly on the homepage.'}

)} {filteredAlbums.map((album) => { const originalIndex = gallery.albums.findIndex((a) => a.id === album.id); return ( removeStandaloneAlbum(originalIndex)} onEdit={() => setEditingAlbumAddress({ type: 'standalone', albumIndex: originalIndex })} /> ); })}
{/* Subpages */}

Subpages

{gallery.subpages.length === 0 && (

No subpages. Create one to group albums under a custom URL path.

)} {gallery.subpages.length > 0 && filteredSubpages.length === 0 && (

No matching subpages found.

)} {/* Collapsed overview grid with DnD */} `subpage-${index}`)} strategy={horizontalListSortingStrategy} >
{filteredSubpages.map(({ sp, index }) => ( setExpandedSubpage(expandedSubpage === index ? null : index)} getFirstThumb={getFirstSubpageThumb} /> ))}
{/* Expanded subpage detail (Slide-Over Drawer) */} {expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
setExpandedSubpage(null)}>
e.stopPropagation()}> {(() => { const sp = gallery.subpages[expandedSubpage]; const spIndex = expandedSubpage; return ( <>
Pages / Subpages

{sp.title || sp.name || 'Untitled Page'}

/{slugify(sp.name)} ↗
{drawerMode === 'preview' ? (

{sp.title || sp.name || 'Untitled Page'}

{sp.subtitle &&

{sp.subtitle}

}
{sp.sections && sp.sections.length > 0 ? ( sp.sections.map((sec, sIdx) => (

{sec.title || 'Untitled Section'}

{sec.description &&

{sec.description}

}
{sec.albums.map((alb, aIdx) => { const immichAlb = immichAlbums.find((a) => a.id === alb.id); const thumb = alb.heroImage || immichAlb?.thumbnailAssetId; return (
{thumb ? ( ) : (
)}
{alb.title || immichAlb?.albumName || alb.id}
); })}
)) ) : (
{sp.albums.map((alb, aIdx) => { const immichAlb = immichAlbums.find((a) => a.id === alb.id); const thumb = alb.heroImage || immichAlb?.thumbnailAssetId; return (
{thumb ? ( ) : (
)}
{alb.title || immichAlb?.albumName || alb.id}
); })}
)}
) : ( <>
updateSubpage(spIndex, { name: e.target.value })} placeholder="e.g. dubai, travel, weddings" /> /{slugify(sp.name)}
{/* Subpage metadata */}
updateSubpage(spIndex, { title: e.target.value || undefined }) } placeholder="Display title (defaults to name)" />
updateSubpage(spIndex, { subtitle: e.target.value || undefined }) } placeholder="Subtitle text" />
updateSubpage(spIndex, { password: e.target.value || undefined }) } placeholder="Leave empty for public access" />
{/* Visual Photo Essay Builder (if layout === 'essay') */} {(sp.grid?.layout === 'essay' || sp.essayText != null) && (

Storytelling & Photo Essay Builder

Compose text, quotes, and fullbleed/wide photos visually.

updateSubpage(spIndex, { essayText: newMarkdown })} onSelectPhoto={(callback) => { setHeroPickerTarget({ albumId: undefined, title: 'Select Photo for Photo Essay', onSelect: (assetId) => { callback(assetId); setHeroPickerTarget(null); }, }); }} />
)}
{/* Albums (if no sections) with DnD */} {(!sp.sections || sp.sections.length === 0) && (
Albums in this Page
`album-${a.id}-${i}`)} strategy={verticalListSortingStrategy} >
{sp.albums.length === 0 && (

No albums added yet. Click + Add Album to pick from Immich.

)} {sp.albums.map((album, aIndex) => ( removeSubpageAlbum(spIndex, aIndex)} onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })} /> ))}
)} {/* Sections */} {sp.sections && sp.sections.length > 0 && (
{sp.sections.map((sec, secIndex) => (
updateSection(spIndex, secIndex, { title: e.target.value }) } placeholder="Section title" />
updateSection(spIndex, secIndex, { description: e.target.value || undefined, }) } placeholder="Section description (optional)" className="section-desc-input" />
{sec.albums.map((album, aIndex) => ( removeSectionAlbum(spIndex, secIndex, aIndex)} onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })} /> ))}
))}
)}
{sp.sections && sp.sections.length > 0 && ( )}
)}
); })()}
)}
{/* Album Picker Modal */} {pickerTarget && ( setPickerTarget(null)} usedAlbumIds={getAllUsedAlbumIds()} /> )} {/* Slide-over Drawer for Album Details (Centered 2-Column Modal) */} {(() => { if (!editingAlbumAddress) return null; const info = getEditingAlbumInfo(editingAlbumAddress); if (!info) return null; const { album, name, count, thumbnailId, onUpdate, onRemove } = info; const heroThumb = album.heroImage || thumbnailId; return (
setEditingAlbumAddress(null)}>
e.stopPropagation()}>

Edit Album Details

{/* One scrolling column: cover banner, then the form. Anything the form already shows (password state, custom hero) is not repeated as a read-only stat. */}
{heroThumb ? ( ) : (
)}
{album.title || name} {count} {count === 1 ? 'photo' : 'photos'} {album.title ? ` · Original: ${name}` : ''}
onUpdate({ title: e.target.value || undefined })} placeholder={name} />