'use client'; import { useState, useEffect, useCallback } 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'; // ── Types ────────────────────────────────────────────────────── interface AlbumEntry { id: string; title?: string; description?: string; password?: string; } interface Section { title: string; description?: string; albums: AlbumEntry[]; } interface Subpage { name: string; title?: string; subtitle?: string; password?: string; sections?: Section[]; albums: AlbumEntry[]; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; } interface GalleryState { hero: string[]; albums: AlbumEntry[]; subpages: Subpage[]; } interface ImmichAlbumInfo { id: string; albumName: string; description: string; thumbnailAssetId: string | null; assetCount: number; isConfigured: boolean; } // ── Helpers ──────────────────────────────────────────────────── function parseAlbumEntries( raw: | Array< string | Record > | 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 }; }); } function serializeAlbumEntries( entries: AlbumEntry[], ): Array< string | Record > { return entries.map((entry) => { if (!entry.title && !entry.description && !entry.password) return entry.id; if (entry.title && !entry.description && !entry.password) { return { [entry.id]: entry.title }; } const val: { title: string; description?: string; password?: string } = { title: entry.title || '', }; if (entry.description) val.description = entry.description; if (entry.password) val.password = entry.password; 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, onUpdate, }: { album: AlbumEntry; index: number; name: string; count: number; thumbnailId: string | null; onRemove: () => void; onUpdate: (updates: Partial) => 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); return (
⠿
{firstThumb ? ( ) : (
📂
)}
{sp.title || sp.name} {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 [pickerTarget, setPickerTarget] = useState<{ type: 'standalone' | 'subpage' | 'section'; subpageIndex?: number; sectionIndex?: number; } | null>(null); const [showHeroPicker, setShowHeroPicker] = useState(false); // 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, 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 }; } 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, 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.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] })); setShowHeroPicker(false); 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 (
); } return (
{/* Save Bar */}
{dirty && Unsaved changes} {saveMessage && ( {saveMessage} )} {!dirty && !saveMessage && ⌘S to save}
↗ Preview Site
{/* 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

`album-${a.id}-${i}`)} strategy={verticalListSortingStrategy} >
{gallery.albums.length === 0 && (

No standalone albums. These show directly on the homepage.

)} {gallery.albums.map((album, i) => ( removeStandaloneAlbum(i)} onUpdate={(updates) => { setGallery((g) => { const albums = [...g.albums]; albums[i] = { ...albums[i], ...updates }; return { ...g, albums }; }); markDirty(); }} /> ))}
{/* Subpages */}

📂 Subpages

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

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

)} {/* Collapsed overview grid with DnD */} `subpage-${i}`)} strategy={horizontalListSortingStrategy} >
{gallery.subpages.map((sp, spIndex) => ( setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)} getFirstThumb={getFirstSubpageThumb} /> ))}
{/* Expanded subpage detail */} {expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
{(() => { const sp = gallery.subpages[expandedSubpage]; const spIndex = expandedSubpage; return ( <>
updateSubpage(spIndex, { name: e.target.value })} placeholder="Page name (used for URL)" onClick={(e) => e.stopPropagation()} /> /{slugify(sp.name)}
e.stopPropagation()} > ↗
{/* 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" />
{/* Albums (if no sections) with DnD */} {(!sp.sections || sp.sections.length === 0) && (
Albums
`album-${a.id}-${i}`)} strategy={verticalListSortingStrategy} >
{sp.albums.map((album, aIndex) => ( removeSubpageAlbum(spIndex, aIndex)} onUpdate={(updates) => { setGallery((g) => { const subpages = [...g.subpages]; const sp2 = { ...subpages[spIndex] }; const albums = [...sp2.albums]; albums[aIndex] = { ...albums[aIndex], ...updates }; sp2.albums = albums; subpages[spIndex] = sp2; return { ...g, subpages }; }); markDirty(); }} /> ))}
)} {/* 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)} onUpdate={(updates) => { setGallery((g) => { const subpages = [...g.subpages]; const sp2 = { ...subpages[spIndex] }; const sections = [...(sp2.sections || [])]; const sec2 = { ...sections[secIndex] }; const albums = [...sec2.albums]; albums[aIndex] = { ...albums[aIndex], ...updates }; sec2.albums = albums; sections[secIndex] = sec2; sp2.sections = sections; subpages[spIndex] = sp2; return { ...g, subpages }; }); markDirty(); }} /> ))}
))}
)}
{sp.sections && sp.sections.length > 0 && ( )}
); })()}
)}
{/* Album Picker Modal */} {pickerTarget && ( setPickerTarget(null)} usedAlbumIds={getAllUsedAlbumIds()} /> )} {/* Hero Asset Picker Modal */} {showHeroPicker && ( setShowHeroPicker(false)} currentAssetIds={gallery.hero} /> )}
); function getAllUsedAlbumIds(): Set { const ids = new Set(); gallery.albums.forEach((a) => ids.add(a.id)); gallery.subpages.forEach((sp) => { sp.albums.forEach((a) => ids.add(a.id)); sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id))); }); return ids; } } // ── Album Card Sub-component ─────────────────────────────────── interface AlbumCardProps { album: AlbumEntry; name: string; count: number; thumbnailId: string | null; onRemove: () => void; onUpdate: (updates: Partial) => void; dragListeners?: Record; } function AlbumCard({ album, name, count, thumbnailId, onRemove, onUpdate, dragListeners, }: AlbumCardProps) { const [expanded, setExpanded] = useState(false); return (
{dragListeners && (
⠿
)} {thumbnailId ? ( ) : (
📷
)}
{album.title || name} {count} photos
{expanded && (
onUpdate({ title: e.target.value || undefined })} placeholder={name} />
onUpdate({ description: e.target.value || undefined })} placeholder="Optional description" />
onUpdate({ password: e.target.value || undefined })} placeholder="Leave empty for public" />
{album.id}
)}
); }