'use client'; import { useState, useEffect, useCallback } from 'react'; import AlbumPicker from './AlbumPicker'; // ── 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 }; }); } // ── 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); // Load data useEffect(() => { loadData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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(''); // Serialize back to YAML-compatible format 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; }); // Also include top-level albums if they exist alongside sections 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); } } // ── 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(); } // ── 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(); } function addHero() { const uuid = prompt('Enter Immich asset UUID for hero image:'); if (uuid?.trim()) { setGallery((g) => ({ ...g, hero: [...g.hero, uuid.trim()] })); markDirty(); } } // ── Move helpers ────────────────────────────────────────────── function moveSubpage(index: number, direction: -1 | 1) { const newIndex = index + direction; if (newIndex < 0 || newIndex >= gallery.subpages.length) return; setGallery((g) => { const subpages = [...g.subpages]; [subpages[index], subpages[newIndex]] = [subpages[newIndex], subpages[index]]; return { ...g, subpages }; }); 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 { // Try albums first for (const album of sp.albums) { const thumb = getAlbumThumbnailId(album.id); if (thumb) return thumb; } // Try sections 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; } // ── Render ─────────────────────────────────────────────────── if (loading) { return (
); } return (
{/* Save Bar */}
{dirty && Unsaved changes} {saveMessage && ( {saveMessage} )}
{/* Hero Section */}

🏠 Homepage Hero

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

No hero images configured. Add asset UUIDs to show a hero carousel.

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

📷 Standalone Albums

{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 */}
{gallery.subpages.map((sp, spIndex) => { const totalAlbums = sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); const firstThumb = getFirstSubpageThumb(sp); return (
setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex) } >
{firstThumb ? ( ) : (
📂
)}
{sp.title || sp.name} {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
); })}
{/* 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()} />
{/* 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) */} {(!sp.sections || sp.sections.length === 0) && (
Albums
{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()} /> )}
); 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; } function AlbumCard({ album, name, count, thumbnailId, onRemove, onUpdate }: AlbumCardProps) { const [expanded, setExpanded] = useState(false); return (
{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}
)}
); }