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:
@@ -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>
|
||||
|
||||
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal file
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal file
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal 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)}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
394
app/admin/components/page-builder/PagePanel.tsx
Normal file
394
app/admin/components/page-builder/PagePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user