The admin was a section each for Home, About Us, Collections, Gallery, Collabs, Drops and Services, because the site's pages were a constant in site-map.ts and a developer was the only one who could add one. A page builder is going to replace that, so the nav stops enumerating pages. What is left is the two things a page is NOT: Layout — the shell every page renders inside — and Pages, listed but not built. Layout is first because it is first in the work: building pages before the frame they sit in means building them twice. The panel is a sketch of the page with the controls under it. Menu across the top, a body, the footer at the bottom, in the chosen colours and typefaces — the same principle the header editor already follows, where the admin renders the structure as the page will show it. It is honest about being a sketch: drawing the logo and the rules would be a second implementation of the layout, and the two would drift the first time either changed. Fonts are a mapping, not free text. styles.css already declares four ROLES — body, sub, title, display — and every template asks for a role rather than a typeface, so choosing a font is choosing which of the four faces the build subsets plays each part. The list travels with the payload rather than being copied here, because a picker offering a face the build never subsets renders as a fallback nobody chose. The per-page panels are NOT deleted. AboutPanel, HomePanel and RecordsPanel are still in the tree and still work; they are unreachable, which is a line in sections.ts rather than a rewrite. The page builder is not finished, and removing the only way to edit the live site's content before its replacement exists would leave a gap with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
505 lines
20 KiB
TypeScript
505 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { GripVertical, Plus, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { useSaveTarget } from '../SaveTarget';
|
|
|
|
/**
|
|
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
|
|
*
|
|
* ── Why this is the first section now ──
|
|
*
|
|
* The admin was one section per page — Home, About, Gallery, Drops — because the site's pages were a
|
|
* constant in a file. They are becoming something somebody builds, so a nav that enumerates them is a
|
|
* nav that has to be edited by a developer every time the client adds a page. Layout is what is left
|
|
* once you take the pages out: the part of every page that is not the page.
|
|
*
|
|
* ── The preview is the point ──
|
|
*
|
|
* It draws the menu across the top, a body, and the footer at the bottom, in the chosen colours and
|
|
* typefaces. That is the same principle the header editor already follows — the admin renders the
|
|
* structure as the page will show it, so what is arranged is what is published.
|
|
*
|
|
* It is a SKETCH and says so: the real page has a logo, a rule, a specific measure. Drawing those here
|
|
* would be a second implementation of the layout, and the two would drift the first time either
|
|
* changed. What it is honest about is the thing being decided — order, wording, colour, typeface.
|
|
*/
|
|
|
|
type LayoutLink = { label: string; href: string };
|
|
type FooterColumn = { title: string; links: LayoutLink[] };
|
|
|
|
type Layout = {
|
|
menu_items: LayoutLink[];
|
|
footer_columns: FooterColumn[];
|
|
footer_note: string | null;
|
|
background: string | null;
|
|
foreground: string | null;
|
|
accent: string | null;
|
|
font_body: string | null;
|
|
font_sub: string | null;
|
|
font_title: string | null;
|
|
font_display: string | null;
|
|
/** What the picker may offer — sent by the API so this file holds no third copy of the list. */
|
|
fonts: string[];
|
|
/** The pages that exist, for the menu's href suggestions. */
|
|
pages: { path: string; label: string }[];
|
|
};
|
|
|
|
/** The four roles styles.css declares. A template asks for a role; this decides which face plays it. */
|
|
const ROLES = [
|
|
{ key: 'font_title' as const, label: 'Headings', hint: 'Page and section titles' },
|
|
{ key: 'font_body' as const, label: 'Body', hint: 'Paragraphs and most text' },
|
|
{ key: 'font_sub' as const, label: 'Menu', hint: 'The navigation and small labels' },
|
|
{ key: 'font_display' as const, label: 'Display', hint: 'The largest statements' },
|
|
];
|
|
|
|
const COLOURS = [
|
|
{ key: 'background' as const, label: 'Background', fallback: '#ffffff' },
|
|
{ key: 'foreground' as const, label: 'Text', fallback: '#000000' },
|
|
{ key: 'accent' as const, label: 'Accent', fallback: '#000000' },
|
|
];
|
|
|
|
export const LayoutPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [stored, setStored] = useState<Layout | null>(null);
|
|
const [draft, setDraft] = useState<Layout | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, flashSaved] = useFlash();
|
|
|
|
const apply = useCallback((data: Layout) => {
|
|
setStored(data);
|
|
setDraft(data);
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
apply((await clientRef.current.get<{ data: Layout }>('/fofos-admin/layout')).data);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
}, [apply]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const patch = (values: Partial<Layout>) => setDraft((was) => (was ? { ...was, ...values } : was));
|
|
|
|
/*
|
|
Compared field by field rather than whole, because the payload carries two things this does not
|
|
own: `fonts` and `pages` are the API telling the client what exists, not values anybody edits.
|
|
Including them would be harmless today and wrong the first time a page is added — the panel would
|
|
show unsaved changes for a page somebody else created.
|
|
*/
|
|
const WRITABLE = [
|
|
'menu_items',
|
|
'footer_columns',
|
|
'footer_note',
|
|
'background',
|
|
'foreground',
|
|
'accent',
|
|
'font_body',
|
|
'font_sub',
|
|
'font_title',
|
|
'font_display',
|
|
] as const;
|
|
|
|
const only = (source: Layout | null) =>
|
|
source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null;
|
|
|
|
const dirty = Boolean(stored && draft) && only(stored) !== only(draft);
|
|
|
|
const save = async () => {
|
|
if (!draft) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const body = Object.fromEntries(WRITABLE.map((key) => [key, draft[key]]));
|
|
apply((await clientRef.current.patch<{ data: Layout }>('/fofos-admin/layout', body)).data);
|
|
flashSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useSaveTarget(dirty, busy, save);
|
|
|
|
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
|
|
|
const menu = draft.menu_items;
|
|
const setMenu = (items: LayoutLink[]) => patch({ menu_items: items });
|
|
|
|
const columns = draft.footer_columns;
|
|
const setColumns = (next: FooterColumn[]) => patch({ footer_columns: next });
|
|
|
|
/** Falls back to what the page itself falls back to, so the sketch is not a different design. */
|
|
const bg = draft.background ?? '#ffffff';
|
|
const fg = draft.foreground ?? '#000000';
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
|
{saved && <p className="text-xs text-success">Saved</p>}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{/* ── The sketch ───────────────────────────────────────────────────────────────────────── */}
|
|
<section className="flex flex-col gap-2">
|
|
<div
|
|
className="overflow-hidden rounded-lg border border-border"
|
|
style={{ backgroundColor: bg, color: fg }}
|
|
>
|
|
{/* The menu, in the order it will appear, in the face chosen for it. */}
|
|
<div
|
|
className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 border-b px-4 py-3 text-[11px]"
|
|
style={{ borderColor: fg, fontFamily: draft.font_sub ?? undefined }}
|
|
>
|
|
{/* The menu when there is one, the site's own pages when there is not — the same fallback
|
|
the page itself applies, so the sketch never shows a menu the site would not draw. */}
|
|
{(menu.length ? menu : draft.pages.map((page) => ({ label: page.label, href: page.path }))).map(
|
|
(item, index) => (
|
|
<span key={index}>{item.label}</span>
|
|
),
|
|
)}
|
|
{!menu.length && !draft.pages.length && <span className="opacity-40">No menu yet</span>}
|
|
</div>
|
|
|
|
{/* The body. Deliberately empty: what goes here is the page, and the page is what this
|
|
section is NOT about. Its only job is to give the two bands something between them. */}
|
|
<div className="px-4 py-10 text-center" style={{ fontFamily: draft.font_title ?? undefined }}>
|
|
<p className="text-lg">The page</p>
|
|
<p className="mt-1 text-[11px] opacity-50" style={{ fontFamily: draft.font_body ?? undefined }}>
|
|
Whatever is built for it goes here
|
|
</p>
|
|
</div>
|
|
|
|
{/* The footer, with the columns as they will fall. */}
|
|
<div className="border-t px-4 py-4" style={{ borderColor: fg }}>
|
|
{columns.length ? (
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
{columns.map((column, index) => (
|
|
<div key={index}>
|
|
{column.title && (
|
|
<p className="mb-1 text-[11px]" style={{ fontFamily: draft.font_sub ?? undefined }}>
|
|
{column.title}
|
|
</p>
|
|
)}
|
|
{column.links.map((link, i) => (
|
|
<p key={i} className="text-[11px] opacity-70">
|
|
{link.label}
|
|
</p>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-[11px] opacity-40">No footer columns yet</p>
|
|
)}
|
|
{draft.footer_note && <p className="mt-3 text-[10px] opacity-60">{draft.footer_note}</p>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<div className="flex items-baseline justify-between">
|
|
<h3 className="text-sm font-medium">Menu</h3>
|
|
{!menu.length && (
|
|
<span className="text-xs text-muted-foreground">Showing the site’s own pages</span>
|
|
)}
|
|
</div>
|
|
|
|
{menu.map((item, index) => (
|
|
<LinkRow
|
|
key={index}
|
|
item={item}
|
|
pages={draft.pages}
|
|
onChange={(next) => setMenu(menu.map((was, i) => (i === index ? next : was)))}
|
|
onRemove={() => setMenu(menu.filter((_, i) => i !== index))}
|
|
onMove={(by) => setMenu(moved(menu, index, by))}
|
|
first={index === 0}
|
|
last={index === menu.length - 1}
|
|
/>
|
|
))}
|
|
|
|
<div className="flex gap-2">
|
|
<Button size="sm" variant="outline" onClick={() => setMenu([...menu, { label: '', href: '' }])} disabled={menu.length >= 12}>
|
|
<Plus className="size-3.5" />
|
|
Add item
|
|
</Button>
|
|
|
|
{/*
|
|
Start from the site's own pages rather than from nothing.
|
|
|
|
The menu falls back to them until somebody sets one, so the first edit is almost always
|
|
"the pages, with one change". Typing all eight back in to make that change is the kind of
|
|
small cruelty that makes people leave a panel alone.
|
|
*/}
|
|
{!menu.length && draft.pages.length > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setMenu(draft.pages.map((page) => ({ label: page.label, href: page.path })))}
|
|
>
|
|
Start from the current pages
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Footer ───────────────────────────────────────────────────────────────────────────── */}
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium">Footer</h3>
|
|
|
|
{columns.map((column, index) => (
|
|
<div key={index} className="flex flex-col gap-2 rounded-md border border-border p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={column.title}
|
|
placeholder="Column heading"
|
|
className="min-w-0 flex-1"
|
|
onChange={(event) =>
|
|
setColumns(columns.map((was, i) => (i === index ? { ...was, title: event.currentTarget.value } : was)))
|
|
}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => setColumns(columns.filter((_, i) => i !== index))}
|
|
title="Remove this column"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{column.links.map((link, i) => (
|
|
<LinkRow
|
|
key={i}
|
|
item={link}
|
|
pages={draft.pages}
|
|
onChange={(next) =>
|
|
setColumns(
|
|
columns.map((was, c) =>
|
|
c === index ? { ...was, links: was.links.map((l, li) => (li === i ? next : l)) } : was,
|
|
),
|
|
)
|
|
}
|
|
onRemove={() =>
|
|
setColumns(
|
|
columns.map((was, c) => (c === index ? { ...was, links: was.links.filter((_, li) => li !== i) } : was)),
|
|
)
|
|
}
|
|
onMove={(by) =>
|
|
setColumns(columns.map((was, c) => (c === index ? { ...was, links: moved(was.links, i, by) } : was)))
|
|
}
|
|
first={i === 0}
|
|
last={i === column.links.length - 1}
|
|
/>
|
|
))}
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="self-start"
|
|
onClick={() =>
|
|
setColumns(
|
|
columns.map((was, c) => (c === index ? { ...was, links: [...was.links, { label: '', href: '' }] } : was)),
|
|
)
|
|
}
|
|
disabled={column.links.length >= 12}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add link
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="self-start"
|
|
onClick={() => setColumns([...columns, { title: '', links: [] }])}
|
|
disabled={columns.length >= 6}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add column
|
|
</Button>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs text-muted-foreground">The line at the very bottom</label>
|
|
<Input
|
|
value={draft.footer_note ?? ''}
|
|
placeholder="© Fofos Fluffy Bastards"
|
|
onChange={(event) => patch({ footer_note: event.currentTarget.value || null })}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Colours ──────────────────────────────────────────────────────────────────────────── */}
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium">Colours</h3>
|
|
|
|
<div className="flex flex-wrap gap-4">
|
|
{COLOURS.map(({ key, label, fallback }) => (
|
|
<div key={key} className="flex items-center gap-2">
|
|
{/*
|
|
The browser's own picker. It sits on the LEFT of a wide row here, which is the whole
|
|
reason it works: the same control at the right-hand edge of this panel opened its popup
|
|
off the side of the window. See OverlayEditor for the version that had to move.
|
|
*/}
|
|
<input
|
|
type="color"
|
|
value={draft[key] ?? fallback}
|
|
onChange={(event) => patch({ [key]: event.currentTarget.value } as Partial<Layout>)}
|
|
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
|
|
title={label}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs">{label}</span>
|
|
<button
|
|
type="button"
|
|
className="text-left text-[10px] text-muted-foreground hover:underline"
|
|
onClick={() => patch({ [key]: null } as Partial<Layout>)}
|
|
>
|
|
{draft[key] ?? 'default'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Typefaces ────────────────────────────────────────────────────────────────────────── */}
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium">Typefaces</h3>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{ROLES.map(({ key, label, hint }) => (
|
|
<div key={key} className="flex flex-col gap-1">
|
|
<label className="text-xs">{label}</label>
|
|
<select
|
|
value={draft[key] ?? ''}
|
|
onChange={(event) => patch({ [key]: event.currentTarget.value || null } as Partial<Layout>)}
|
|
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
|
>
|
|
{/* "Default" is not a font. It means the stylesheet decides, which is what an
|
|
untouched site already does — so choosing it puts things back rather than
|
|
setting something new. */}
|
|
<option value="">Default</option>
|
|
{draft.fonts.map((font) => (
|
|
<option key={font} value={font}>
|
|
{font}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** Reordering, which is the one thing a menu needs more than editing. */
|
|
const moved = <T,>(items: T[], from: number, by: number): T[] => {
|
|
const to = from + by;
|
|
if (to < 0 || to >= items.length) return items;
|
|
const next = [...items];
|
|
const [item] = next.splice(from, 1);
|
|
next.splice(to, 0, item!);
|
|
return next;
|
|
};
|
|
|
|
/**
|
|
* One link: what it says, and where it goes.
|
|
*
|
|
* The href is a text field with the site's own pages offered beside it rather than a dropdown of
|
|
* them. A menu item can point at an anchor, an external address, or a page that does not exist yet —
|
|
* and while a site is being built the last of those is the common case, so a control that could only
|
|
* express "one of the pages that already exist" would be in the way most of the time.
|
|
*/
|
|
const LinkRow = ({
|
|
item,
|
|
pages,
|
|
onChange,
|
|
onRemove,
|
|
onMove,
|
|
first,
|
|
last,
|
|
}: {
|
|
item: LayoutLink;
|
|
pages: { path: string; label: string }[];
|
|
onChange: (item: LayoutLink) => void;
|
|
onRemove: () => void;
|
|
onMove: (by: number) => void;
|
|
first: boolean;
|
|
last: boolean;
|
|
}) => {
|
|
const known = pages.some((page) => page.path === item.href);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex shrink-0 flex-col text-muted-foreground">
|
|
<button type="button" className="leading-none hover:text-foreground disabled:opacity-30" onClick={() => onMove(-1)} disabled={first} title="Move up">
|
|
▴
|
|
</button>
|
|
<button type="button" className="leading-none hover:text-foreground disabled:opacity-30" onClick={() => onMove(1)} disabled={last} title="Move down">
|
|
▾
|
|
</button>
|
|
</div>
|
|
|
|
<GripVertical className="size-3.5 shrink-0 text-muted-foreground/40" />
|
|
|
|
<Input
|
|
value={item.label}
|
|
placeholder="Label"
|
|
className="min-w-0 flex-1"
|
|
onChange={(event) => onChange({ ...item, label: event.currentTarget.value })}
|
|
/>
|
|
|
|
<div className="relative min-w-0 flex-1">
|
|
<Input
|
|
value={item.href}
|
|
placeholder="/where-it-goes"
|
|
list="fofos-pages"
|
|
onChange={(event) => onChange({ ...item, href: event.currentTarget.value })}
|
|
/>
|
|
{/* Said rather than enforced. An unknown href is usually a page not built yet, not a mistake,
|
|
and refusing it would make this panel unusable during exactly the phase it is for. */}
|
|
{item.href && !known && !item.href.startsWith('http') && !item.href.startsWith('#') && (
|
|
<span className="pointer-events-none absolute -bottom-4 left-0 text-[10px] text-muted-foreground">
|
|
no page there yet
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<datalist id="fofos-pages">
|
|
{pages.map((page) => (
|
|
<option key={page.path} value={page.path}>
|
|
{page.label}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
|
onClick={onRemove}
|
|
title="Remove"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|