'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/.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(null); const [loadError, setLoadError] = useState(null); const [title, setTitle] = useState(''); const [slugInput, setSlugInput] = useState(slug); const [draft, setDraft] = useState(false); const [password, setPassword] = useState(undefined); const [description, setDescription] = useState(''); const [saving, setSaving] = useState(false); /** The page file as loaded, sent back on save (#601). */ const versionRef = useRef(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 (

/{slug}

This page could not be loaded. {loadError} {inMenu && (

The menu still lists it. Switch off “Show in menu” and save the structure to remove the entry.

)}
{inMenu && ( )}
); } if (!parsed) { return (
); } return (

{fm?.title || slug}

/{slug} ↗
setTitle(e.target.value)} />
setSlugInput(slugFieldValue(e.target.value))} aria-describedby="page-slug-hint" /> /{targetSlug}
{collision ? collision : renaming ? `Links to /${slug} will stop working. There is no automatic redirect.` : 'Checked against subpages, albums, journal entries and built-in routes.'}
Password (optional)