);
}
// ── Component ──────────────────────────────────────────────────
export default function PageBuilder() {
const [gallery, setGallery] = useState({ hero: [], albums: [], subpages: [] });
const [immichAlbums, setImmichAlbums] = useState([]);
const [loading, setLoading] = useState(true);
/**
* Set when gallery.yaml could not be fetched. It blocks saving: the builder
* writes the whole file from its own state, so saving an empty builder
* publishes an empty gallery.
*/
const [loadError, setLoadError] = useState(null);
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(null);
const [heroPickerTarget, setHeroPickerTarget] = useState(null);
const [orderEditorTarget, setOrderEditorTarget] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [editingAlbumAddress, setEditingAlbumAddress] = useState(
null,
);
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
const [linkedAlbumId, setLinkedAlbumId] = 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]);
// ── Escape closes the subpage sheet — only when it is topmost ─
useEffect(() => {
if (expandedSubpage === null) return;
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
// Listbox preventDefaults its own Escape but does not stopPropagation —
// respect that so closing a popup never also closes the sheet.
if (e.defaultPrevented) return;
// A higher layer (album editor, pickers, order editor) owns the key.
if (editingAlbumAddress || pickerTarget || heroPickerTarget || orderEditorTarget) return;
setExpandedSubpage(null);
}
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [expandedSubpage, editingAlbumAddress, pickerTarget, heroPickerTarget, orderEditorTarget]);
useUnsavedGuard(dirty);
// Unsaved edits survive leaving the builder — a tab link, back, Reload,
// Logout (#592). `serverState` is what a discard goes back to.
const draft = useDraft('page-builder', gallery, dirty);
const serverState = useRef(null);
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
// linked one into view, and let the mark fade after a moment. The mark itself
// is a prop on the tile — a class added to the DOM here would be wiped by the
// next render of the tile.
useEffect(() => {
if (!linkedAlbumId || expandedSubpage === null) return;
const frame = requestAnimationFrame(() => {
const tile = document.querySelector(
`.subpage-drawer-container [data-album-id="${window.CSS.escape(linkedAlbumId)}"]`,
);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
tile?.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
});
const fade = window.setTimeout(() => setLinkedAlbumId(null), 2600);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(fade);
};
}, [linkedAlbumId, expandedSubpage]);
async function loadData() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
]);
if (!galleryRes.ok) {
throw new Error(
galleryRes.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${galleryRes.status}.`,
);
}
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
serverState.current = parsed;
const restored = draft.load(JSON.stringify(parsed));
setGallery(restored ?? parsed);
if (restored) setDirty(true);
openAlbumFromLink(restored ?? parsed);
// A failed album list is survivable — it only empties the picker, and
// saving with it empty changes nothing in gallery.yaml.
if (albumsRes.ok) {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
} finally {
setLoading(false);
}
}
/**
* `?album=` opens the place that album is published from — the
* diagnostics page links here from a finding about one album. That is the
* subpage's sheet when the album sits on a subpage: the fix for such a
* finding (take it off the page, or check which page shows it) lives there,
* not in the album's own details. A standalone album has no page, so its
* own drawer is the place.
*
* One-shot: the parameter is dropped once read, so a reload after closing
* the sheet does not open it again.
*/
function openAlbumFromLink(state: GalleryState) {
const params = new URLSearchParams(window.location.search);
const albumId = params.get('album');
if (!albumId) return;
params.delete('album');
const query = params.toString();
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
const address = findAlbumAddress(state, albumId);
if (!address) return;
if (address.subpageIndex !== undefined) {
setExpandedSubpage(address.subpageIndex);
setLinkedAlbumId(albumId);
} else {
setEditingAlbumAddress(address);
}
}
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,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
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'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(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,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
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'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
};
});
}
return { hero, albums, subpages };
}
function discardDraft() {
draft.discard();
if (serverState.current) setGallery(serverState.current);
setDirty(false);
setSaveMessage('');
}
function restoreConflictingDraft() {
const value = draft.takeConflicting();
if (!value) return;
setGallery(value);
setDirty(true);
}
const markDirty = useCallback(() => {
setDirty(true);
setSaveMessage('');
}, []);
// ── Save ──────────────────────────────────────────────────────
async function handleSave() {
// Not reachable from the UI in this state, but Cmd+S still gets here.
if (loadError) return;
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.hidden === true) entry.hidden = true;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
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();
// Fingerprint what the next load will see, the way it will see it: the
// file is exactly `yamlData`, read back through the same parser. The
// editor's own state can differ in shape (an `undefined` here, a
// default there) and would make every later draft look outdated.
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(yamlData)));
serverState.current = asLoaded;
draft.saved(JSON.stringify(asLoaded));
setDirty(false);
setSaveMessage(data.message || 'Saved successfully!');
setTimeout(() => setSaveMessage(''), 5000);
} else if (!reportIfSessionExpired(res)) {
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();
}
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
function enabledPosition(index: number): number | undefined {
const sp = gallery.subpages[index];
if (!sp || sp.enabled === false) return undefined;
return gallery.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
}
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 ────────────────────────────────────────────
// Confirmed like removeSubpage: an album entry carries its grid overrides,
// cover asset and manual assetOrder with it, there is no undo stack in the
// builder, and the only escape from an accidental click used to be
// reloading the page — which discards every other unsaved edit too (#597).
function removeStandaloneAlbum(index: number) {
if (!confirm('Remove this album from the gallery?')) return;
setGallery((g) => ({
...g,
albums: g.albums.filter((_, i) => i !== index),
}));
markDirty();
}
function removeSubpageAlbum(subpageIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the subpage?')) return;
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) {
if (!confirm('Remove this album from the section?')) return;
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) {
if (!confirm('Remove this photo from the home page hero?')) return;
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;
}
// ── Render ───────────────────────────────────────────────────
if (loading) {
return (
);
}
/**
* Replaces the builder rather than sitting above it. An empty builder looks
* exactly like a site with no pages yet, and saving it used to publish an
* empty gallery.yaml over a working one.
*/
if (loadError) {
return (
The page structure could not be loaded. {loadError}
Nothing has been changed. Saving stays disabled until it loads, so an empty builder cannot
overwrite your gallery.