release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -6,6 +6,7 @@ import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
album: AlbumEntry;
@@ -36,15 +37,29 @@ export default function AlbumDrawer({
onEditOrder,
}: AlbumDrawerProps) {
const heroThumb = album.heroImage || thumbnailId;
const cardRef = useModalDialog(onClose);
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div
className="album-drawer"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-drawer-title"
onClick={(e) => e.stopPropagation()}
>
<div className="album-drawer-header">
<h3>
<h3 id="album-drawer-title">
<IconCamera /> Edit Album Details
</h3>
<button className="admin-btn-icon" onClick={() => onClose()} title="Close details">
<button
type="button"
className="admin-btn-icon"
onClick={() => onClose()}
title="Close details"
aria-label="Close details"
>
<IconX size={14} />
</button>
</div>

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { describeCollision, pageSlugCollision, type SlugTakenBy } from '@/lib/pages';
import { useModalDialog } from '@/hooks/useModalDialog';
interface NewPageDialogProps {
taken: SlugTakenBy;
existingPageSlugs: string[];
creating: boolean;
onCreate: (input: { title: string; slug: string; showInMenu: boolean }) => void;
onClose: () => void;
}
/**
* "+ New page" (#722): a title, the slug derived from it (editable), and
* whether the page goes into the menu. v1 starts every page blank.
*/
export default function NewPageDialog({
taken,
existingPageSlugs,
creating,
onCreate,
onClose,
}: NewPageDialogProps) {
const cardRef = useModalDialog(onClose);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [showInMenu, setShowInMenu] = useState(true);
/** What the field shows: the typed slug, or the one derived from the title. */
const fieldSlug = slug || (title.trim() ? sanitizeSlug(title) : '');
/** What gets created: the typed slug finished, e.g. without a trailing "-". */
const effectiveSlug = slug ? sanitizeSlug(slug) : fieldSlug;
const collision = !effectiveSlug
? null
: existingPageSlugs.includes(effectiveSlug)
? `A page "${effectiveSlug}" already exists.`
: (() => {
const c = pageSlugCollision(effectiveSlug, taken);
return c ? describeCollision(effectiveSlug, c) : null;
})();
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !effectiveSlug || collision || creating) return;
onCreate({ title: title.trim(), slug: effectiveSlug, showInMenu });
};
return (
<div className="confirm-backdrop" onClick={onClose}>
<div
ref={cardRef}
className="confirm-card new-page-card"
role="dialog"
aria-modal="true"
aria-labelledby="new-page-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="new-page-title" className="confirm-title">
New page
</h2>
<form onSubmit={submit} className="new-page-form">
<div className="admin-field">
<label htmlFor="new-page-name">Title</label>
<input
id="new-page-name"
value={title}
placeholder="e.g. Pricing"
autoFocus
onChange={(e) => {
setTitle(e.target.value);
if (!slugEdited) setSlug('');
}}
/>
</div>
<div className="admin-field">
<label htmlFor="new-page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="new-page-slug"
className="subpage-name-input"
value={fieldSlug}
placeholder="pricing"
aria-describedby="new-page-slug-hint"
onChange={(e) => {
setSlugEdited(e.target.value !== '');
setSlug(slugFieldValue(e.target.value));
}}
/>
<span className="input-slug-preview">/{effectiveSlug || 'slug'}</span>
</div>
<span
id="new-page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : ''}`}
>
{collision ?? 'Starts blank and as a draft. You can change the URL later.'}
</span>
</div>
<button
type="button"
className="setting-row"
onClick={() => setShowInMenu(!showInMenu)}
aria-pressed={showInMenu}
>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
Adds it to the end of the menu; saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${showInMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<div className="confirm-actions">
<button type="button" className="admin-btn admin-btn-ghost" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={!title.trim() || !effectiveSlug || !!collision || creating}
>
{creating ? 'Creating…' : 'Create page'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useRouter } from 'next/navigation';
import { JournalEditor } from '../journal/JournalEditor';
/**
* The journal's block editor and live preview in page mode (#722). Back goes
* to Pages with the page selected again.
*/
export function PageContentEditor({ slug }: { slug: string }) {
const router = useRouter();
return (
<JournalEditor
slug={slug}
kind="page"
onBack={() => router.push(`/admin/pages?page=${encodeURIComponent(slug)}`)}
/>
);
}

View File

@@ -0,0 +1,394 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import {
parseJournalMarkdown,
sanitizeSlug,
serializeJournalMarkdown,
slugFieldValue,
type ParsedJournal,
} from '@/lib/journal';
import {
describeCollision,
pageSlugCollision,
type PageSummary,
type SlugTakenBy,
} from '@/lib/pages';
import PasswordField from '../fields/PasswordField';
import { IconFileText, IconTrash } from '../Icons';
import { useConfirm } from '../ConfirmDialog';
import { useNotify } from '../Notifications';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useVersionedSave } from '../useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
interface PagePanelProps {
slug: string;
/** Whether gallery.yaml (as edited in the builder) lists the page. */
inMenu: boolean;
onToggleMenu: () => void;
/** Slugs a page may not take. */
taken: SlugTakenBy;
/** The other pages' slugs, which a rename must not take either. */
otherPageSlugs: string[];
/**
* `galleryVersion` is set when the server rewrote gallery.yaml's menu, so
* the builder can follow that change without mistaking it for a foreign
* edit (#601).
*/
onSaved: (
oldSlug: string,
page: PageSummary,
menuRenamed: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
onDeleted: (
slug: string,
removedFromMenu: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
}
/**
* A content page selected in the structure list (#722): its settings, saved
* to content/pages/<slug>.md with the panel's own button. "Show in menu" is
* the one exception — it edits gallery.yaml, so it goes with the builder's
* Save like every other change to the menu.
*/
export default function PagePanel({
slug,
inMenu,
onToggleMenu,
taken,
otherPageSlugs,
onSaved,
onDeleted,
}: PagePanelProps) {
const confirm = useConfirm();
const notify = useNotify();
const [parsed, setParsed] = useState<ParsedJournal | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [slugInput, setSlugInput] = useState(slug);
const [draft, setDraft] = useState(false);
const [password, setPassword] = useState<string | undefined>(undefined);
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
/** The page file as loaded, sent back on save (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** Bumped to load the file again — "Reload" in the conflict prompt. */
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setParsed(null);
setLoadError(null);
fetch(`/api/admin/pages/${encodeURIComponent(slug)}`)
.then(async (res) => {
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(
res.status === 404
? 'There is no file for this page in content/pages/.'
: `The server answered ${res.status}.`,
);
}
return res.json();
})
.then((data: { page: { rawMarkdown: string }; version?: string }) => {
if (cancelled) return;
versionRef.current = data.version ?? null;
const p = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(p);
setTitle(p.frontmatter.title || '');
setSlugInput(slug);
setDraft(!!p.frontmatter.draft);
setPassword(p.frontmatter.password || undefined);
setDescription(p.frontmatter.description || '');
})
.catch((err: unknown) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Could not load.');
});
return () => {
cancelled = true;
};
}, [slug, reloadKey]);
// The field keeps what is typed, a trailing "-" included; the page moves to
// the finished slug.
const targetSlug = slugInput ? sanitizeSlug(slugInput) : '';
const fm = parsed?.frontmatter;
const dirty =
!!fm &&
(title !== (fm.title || '') ||
targetSlug !== slug ||
draft !== !!fm.draft ||
(password || '') !== (fm.password || '') ||
description !== (fm.description || ''));
const renaming = targetSlug !== slug;
const collision = renaming
? otherPageSlugs.includes(targetSlug)
? `A page "${targetSlug}" already exists.`
: (() => {
const c = pageSlugCollision(targetSlug, taken);
return c ? describeCollision(targetSlug, c) : null;
})()
: null;
async function save() {
if (!parsed || !dirty || collision || !title.trim()) return;
if (renaming) {
const ok = await confirm({
title: `Move this page to /${targetSlug}?`,
message: `Links to /${slug} will stop working. There is no automatic redirect.`,
confirmLabel: 'Move page',
});
if (!ok) return;
}
setSaving(true);
try {
const markdown = serializeJournalMarkdown({
...parsed,
frontmatter: {
...parsed.frontmatter,
title: title.trim(),
draft: draft || undefined,
password: password || undefined,
description: description.trim() || undefined,
},
});
const result = await versionedSave(
`/api/admin/pages/${encodeURIComponent(slug)}`,
{ rawMarkdown: markdown, ...(renaming ? { newSlug: targetSlug } : {}) },
versionRef,
'This page',
);
if (result.kind === 'reload') {
setReloadKey((k) => k + 1);
return;
}
if (result.kind === 'keep') {
notify('error', 'Not saved — the page changed elsewhere. Your edits are still here.');
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save the page');
}
return;
}
const data = result.data as {
page: { slug: string; rawMarkdown: string };
menuRenamed?: boolean;
galleryVersion?: GalleryVersionChange | null;
/** The page was saved, but its menu entry in gallery.yaml was not updated. */
warning?: string;
};
const written = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(written);
setPassword(written.frontmatter.password || undefined);
const { title: t, description: d, password: pw, draft: dr } = written.frontmatter;
onSaved(
slug,
{
slug: data.page.slug,
frontmatter: { title: t, description: d, password: pw, draft: dr },
},
data.menuRenamed === true,
data.galleryVersion,
);
if (data.warning) notify('error', data.warning);
else notify('success', 'Page saved.');
} catch {
notify('error', 'Could not save the page. Check the connection and try again.');
} finally {
setSaving(false);
}
}
async function remove() {
const ok = await confirm({
title: `Delete “${title || slug}”?`,
message: inMenu
? 'The page is also removed from the menu in gallery.yaml. Both files are backed up first and can be restored under Backups.'
: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete page',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/pages/${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!reportIfSessionExpired(res))
notify('error', data?.error || 'Failed to delete the page');
return;
}
onDeleted(slug, data?.removedFromMenu === true, data?.galleryVersion);
if (typeof data?.warning === 'string') notify('error', data.warning);
else notify('success', 'Page deleted.');
} catch {
notify('error', 'Could not delete the page. Check the connection and try again.');
}
}
if (loadError) {
return (
<section className="builder-section">
<div className="builder-section-header">
<h2>
<IconFileText /> /{slug}
</h2>
</div>
<div className="admin-error" role="alert">
<strong>This page could not be loaded.</strong> {loadError}
{inMenu && (
<p>
The menu still lists it. Switch off “Show in menu” and save the structure to remove
the entry.
</p>
)}
</div>
{inMenu && (
<button type="button" className="admin-btn admin-btn-secondary" onClick={onToggleMenu}>
Remove from menu
</button>
)}
</section>
);
}
if (!parsed) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<section className="builder-section page-panel">
<div className="builder-section-header">
<h2>
<IconFileText /> {fm?.title || slug}
</h2>
<a
href={`/${encodeURIComponent(slug)}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-xs admin-btn-ghost"
title="Open the live page in a new tab"
>
/{slug} ↗
</a>
</div>
<div className="admin-field">
<label htmlFor="page-title">Title</label>
<input id="page-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="page-slug"
className="subpage-name-input"
value={slugInput}
onChange={(e) => setSlugInput(slugFieldValue(e.target.value))}
aria-describedby="page-slug-hint"
/>
<span className="input-slug-preview">/{targetSlug}</span>
</div>
<span
id="page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : renaming ? ' admin-field-hint--warn' : ''}`}
>
{collision
? collision
: renaming
? `Links to /${slug} will stop working. There is no automatic redirect.`
: 'Checked against subpages, albums, journal entries and built-in routes.'}
</span>
</div>
<div className="page-panel-switches">
<button type="button" className="setting-row" onClick={onToggleMenu} aria-pressed={inMenu}>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
{inMenu
? 'Listed in the header menu. Drag it in the structure list to move it.'
: 'Not in the menu, but reachable at its URL — for pages shared by link.'}{' '}
Saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${inMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<button
type="button"
className="setting-row"
onClick={() => setDraft(!draft)}
aria-pressed={draft}
>
<span className="setting-row-info">
<span className="setting-row-title">Draft</span>
<span className="setting-row-desc">
Only you can see a draft. It stays out of the menu and the sitemap.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${draft ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
</div>
<div className="admin-field">
<span className="admin-field-label">Password (optional)</span>
<PasswordField value={password} onChange={setPassword} label="Page password" />
</div>
<div className="admin-field">
<label htmlFor="page-description">SEO description</label>
<textarea
id="page-description"
rows={2}
value={description}
placeholder="One or two sentences for search engines and link previews"
onChange={(e) => setDescription(e.target.value.replace(/\s*\n\s*/g, ' '))}
/>
</div>
<div className="page-panel-actions">
<Link href={`/admin/pages/edit/${slug}`} className="admin-btn admin-btn-secondary">
Edit content →
</Link>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={save}
disabled={!dirty || saving || !!collision || !title.trim()}
>
{saving ? 'Saving…' : 'Save page'}
</button>
<button
type="button"
className="admin-btn admin-btn-danger"
onClick={remove}
style={{ marginLeft: 'auto' }}
>
<IconTrash size={14} /> Delete
</button>
</div>
</section>
);
}

View File

@@ -6,8 +6,10 @@
*/
import { useSortable } from '@dnd-kit/sortable';
import { useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { ReactNode } from 'react';
import { IconFileText, IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { Subpage } from './types';
// ── Sortable Hero Tile ─────────────────────────────────────────
@@ -211,3 +213,79 @@ export function SortableSubpageRow({
</div>
);
}
// ── Sortable Page Row (#722) ───────────────────────────────────
/**
* A content page in the structure list: document icon and a PAGE tag, so it
* reads as something other than a subpage. The same row sits in the menu
* list and in "Not in menu"; `id` says which, for the drag handler.
*/
export function SortablePageRow({
id,
title,
isActive,
draft,
hasPassword,
missing,
onClick,
}: {
id: string;
title: string;
isActive: boolean;
draft?: boolean;
hasPassword?: boolean;
/** Referenced from gallery.yaml, but there is no file for it. */
missing?: boolean;
onClick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${draft || missing ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
<span className="pb-row-thumb">
<IconFileText size={14} />
</span>
<span className="pb-row-name">
{title}
{hasPassword && <IconLock size={11} />}
</span>
{missing ? (
<span className="pb-row-state">Missing</span>
) : draft ? (
<span className="pb-row-state">Draft</span>
) : (
<span className="pb-row-tag">Page</span>
)}
</button>
</div>
);
}
/**
* The "Not in menu" group as a drop target, so a page dragged out of the
* menu lands there even when the group is empty (#722).
*/
export function OffMenuDropZone({ id, children }: { id: string; children: ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={`pb-offmenu ${isOver ? 'is-over' : ''}`}>
{children}
</div>
);
}

View File

@@ -90,6 +90,15 @@ export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
hero: [...g.hero, assetId],
});
/** Append several hero photos in pick order, skipping any already listed. */
export const addHeroes = (g: GalleryState, assetIds: string[]): GalleryState => ({
...g,
hero: [
...g.hero,
...assetIds.filter((id, i) => !g.hero.includes(id) && assetIds.indexOf(id) === i),
],
});
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -113,6 +122,14 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
...g,
subpages: g.subpages.filter((_, i) => i !== index),
// A page after the removed subpage moves up with the rest of the menu.
...(g.pageRefs
? {
pageRefs: g.pageRefs.map((r) =>
r.position > index ? { ...r, position: r.position - 1 } : r,
),
}
: {}),
});
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
@@ -142,6 +159,98 @@ export const updateSection = (
updates: Partial<Section>,
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
// ── Menu: subpages and content pages together (#722) ────────────
/** One row of the menu list: a subpage by its index, or a page by its slug. */
export type MenuItem = { kind: 'subpage'; index: number } | { kind: 'page'; slug: string };
/** The menu in the order it is saved and shown: subpages with pages interleaved. */
export function menuItems(g: GalleryState): MenuItem[] {
const refs = g.pageRefs ?? [];
const out: MenuItem[] = [];
for (let i = 0; i <= g.subpages.length; i++) {
for (const ref of refs) {
if (Math.min(ref.position, g.subpages.length) === i)
out.push({ kind: 'page', slug: ref.slug });
}
if (i < g.subpages.length) out.push({ kind: 'subpage', index: i });
}
return out;
}
/** Rebuild the state from a reordered menu list. */
function fromMenu(g: GalleryState, items: MenuItem[]): GalleryState {
const subpages: GalleryState['subpages'] = [];
const pageRefs: NonNullable<GalleryState['pageRefs']> = [];
for (const item of items) {
if (item.kind === 'subpage') subpages.push(g.subpages[item.index]);
else pageRefs.push({ slug: item.slug, position: subpages.length });
}
return { ...g, subpages, pageRefs };
}
/**
* Move a menu row from one position to another. Subpage indexes change with
* it; `followMenuMove` tells the builder where an open subpage went.
*/
export function moveMenuItem(g: GalleryState, from: number, to: number): GalleryState {
const items = menuItems(g);
if (!items[from] || !items[to]) return g;
return fromMenu(g, arrayMove(items, from, to));
}
/** The index an open subpage has after moveMenuItem(from, to). */
export function followMenuMove(
g: GalleryState,
open: number | null,
from: number,
to: number,
): number | null {
if (open === null) return null;
const moved = arrayMove(menuItems(g), from, to);
let index = 0;
for (const item of moved) {
if (item.kind !== 'subpage') continue;
if (item.index === open) return index;
index++;
}
return open;
}
export function isPageInMenu(g: GalleryState, slug: string): boolean {
return (g.pageRefs ?? []).some((r) => r.slug === slug);
}
/**
* Put a page in the menu or take it out. `at` is a menu row index to insert
* before; without one the page goes to the end of the menu.
*/
export function setPageInMenu(
g: GalleryState,
slug: string,
inMenu: boolean,
at?: number,
): GalleryState {
const without = (g.pageRefs ?? []).filter((r) => r.slug !== slug);
if (!inMenu) {
return isPageInMenu(g, slug) ? { ...g, pageRefs: without } : g;
}
if (isPageInMenu(g, slug)) return g;
const items = menuItems(g);
const index = at === undefined ? items.length : Math.max(0, Math.min(at, items.length));
items.splice(index, 0, { kind: 'page', slug });
return fromMenu(g, items);
}
/** Follow a page rename, keeping its place in the menu. */
export function renamePageRef(g: GalleryState, from: string, to: string): GalleryState {
if (!isPageInMenu(g, from)) return g;
return {
...g,
pageRefs: (g.pageRefs ?? []).map((r) => (r.slug === from ? { ...r, slug: to } : r)),
};
}
// ── Reading ─────────────────────────────────────────────────────
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */

View File

@@ -14,6 +14,22 @@ export interface GalleryState {
hero: string[];
albums: AlbumEntry[];
subpages: Subpage[];
/**
* Content pages in the menu (#722): `- page: <slug>` among the subpages.
* Optional so a draft saved before pages existed still loads.
*/
pageRefs?: PageRef[];
}
/**
* A content page placed in the menu. `position` is the number of subpages in
* front of it, so the subpage list keeps its own indexes — every drawer and
* album address in the builder is one — and the menu order is rebuilt from
* both lists when saving. Refs sharing a position keep their array order.
*/
export interface PageRef {
slug: string;
position: number;
}
/**
@@ -27,8 +43,18 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
);
let subpages: Subpage[] = [];
const pageRefs: PageRef[] = [];
if (Array.isArray(raw.subpages)) {
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
const entries = raw.subpages as Array<Record<string, unknown>>;
const subpageEntries: Array<Record<string, unknown>> = [];
for (const entry of entries) {
if (entry && typeof entry.page === 'string') {
pageRefs.push({ slug: entry.page, position: subpageEntries.length });
} else {
subpageEntries.push(entry);
}
}
subpages = subpageEntries.map((sp) => ({
name: (sp.name as string) || '',
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
@@ -77,7 +103,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
});
}
return { hero, albums, subpages };
return { hero, albums, subpages, pageRefs };
}
/** The gallery.yaml object for a builder state. Empty groups are left out. */
@@ -90,8 +116,9 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
if (gallery.albums.length > 0) {
yamlData.albums = serializeAlbumEntries(gallery.albums);
}
if (gallery.subpages.length > 0) {
yamlData.subpages = gallery.subpages.map((sp) => {
const pageRefs = gallery.pageRefs ?? [];
if (gallery.subpages.length > 0 || pageRefs.length > 0) {
const subpageEntries = gallery.subpages.map((sp) => {
const entry: Record<string, unknown> = { name: sp.name };
if (sp.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle;
@@ -124,7 +151,25 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
return entry;
});
yamlData.subpages = interleave(subpageEntries, pageRefs);
}
return yamlData;
}
/** Subpage entries with `{ page }` entries placed at their positions. */
function interleave(
subpageEntries: Record<string, unknown>[],
pageRefs: PageRef[],
): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (let i = 0; i <= subpageEntries.length; i++) {
// A ref beyond the end (a subpage was removed) lands at the end.
for (const ref of pageRefs) {
const at = Math.min(ref.position, subpageEntries.length);
if (at === i) out.push({ page: ref.slug });
}
if (i < subpageEntries.length) out.push(subpageEntries[i]);
}
return out;
}

View File

@@ -111,7 +111,10 @@ export interface PickerTarget {
export interface HeroPickerTarget {
albumId?: string;
onSelect: (assetId: string) => void;
/** Single pick; the picker closes after it. */
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
currentAssetIds?: string[];
title?: string;
}