Layout replaces one section per page
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>
This commit is contained in:
+2
-13
@@ -4,13 +4,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections';
|
||||
import { AdministrationPanel } from './AdministrationPanel';
|
||||
import { MediaPanel } from './MediaPanel';
|
||||
import { HomePanel } from './sections/HomePanel';
|
||||
import { GalleriesPanel } from './sections/GalleriesPanel';
|
||||
import { LayoutPanel } from './sections/LayoutPanel';
|
||||
import { PublishButton } from './PublishButton';
|
||||
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
|
||||
import { AboutPanel } from './sections/AboutPanel';
|
||||
import { RecordsPanel } from './sections/RecordsPanel';
|
||||
import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records';
|
||||
import { MessagesPanel } from './MessagesPanel';
|
||||
import { PlannedSection } from './PlannedSection';
|
||||
import { useReady } from './useReady';
|
||||
@@ -47,15 +44,7 @@ import { useReady } from './useReady';
|
||||
* silently showing somebody else's.
|
||||
*/
|
||||
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
||||
home: HomePanel,
|
||||
about: AboutPanel,
|
||||
/*
|
||||
Three sections, one panel, configured — the same split the site's API already makes between
|
||||
`records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries.
|
||||
*/
|
||||
gallery: () => <RecordsPanel config={GALLERY_CONFIG} />,
|
||||
drops: () => <RecordsPanel config={DROPS_CONFIG} />,
|
||||
collabs: () => <RecordsPanel config={COLLABS_CONFIG} />,
|
||||
layout: LayoutPanel,
|
||||
galleries: GalleriesPanel,
|
||||
messages: MessagesPanel,
|
||||
administration: AdministrationPanel,
|
||||
|
||||
+45
-97
@@ -1,21 +1,30 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog, FolderOpen } from 'lucide-react';
|
||||
import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The sections of the fofos admin, in nav order.
|
||||
*
|
||||
* ── The order is the client's site, not the database's ──
|
||||
* ── There is no longer one section per page ──
|
||||
*
|
||||
* It follows the sitemap: the pages someone edits most, first. Site settings, Messages and
|
||||
* Administration sit at the bottom because they are not pages — the site's settings, an inbox, and the
|
||||
* plugin's own machinery.
|
||||
* There was: Home, About Us, Collections, Gallery, Collabs, Drops, Services — a section each, because
|
||||
* the site's pages were a constant in `site-map.ts` and a developer was the only one who could add
|
||||
* one. Every new page meant a new entry here, a new panel, and a new API route.
|
||||
*
|
||||
* ── `site` and `administration` are different things ──
|
||||
* That model is being replaced by a page builder, so the nav stops enumerating pages. What is left is
|
||||
* the two things a page is not:
|
||||
*
|
||||
* "Site settings" is the CLIENT's — partners, social links, contact details, all of it public copy that
|
||||
* whoever edits the site edits. "Administration" is OFFICER's end: where fofos is, how to reach it, and
|
||||
* whatever else this plugin needs to know to do its job. One is content, the other is plumbing, and the
|
||||
* people who touch them are not the same people. Nothing in Administration ever appears on the site.
|
||||
* Layout the shell every page renders inside — the menu, the footer, the colours, the typefaces
|
||||
* Pages the pages themselves, built rather than hard-coded
|
||||
*
|
||||
* The per-page panels have NOT been deleted. `AboutPanel`, `HomePanel` and `RecordsPanel` are still in
|
||||
* the tree and still work; they are simply not reachable, which is a line in this file rather than a
|
||||
* rewrite. That is deliberate — the page builder is not finished, and deleting the only way to edit
|
||||
* the live site's content before its replacement exists would be a gap with nothing in it.
|
||||
*
|
||||
* ── `administration` ──
|
||||
*
|
||||
* OFFICER's end: where fofos is, how to reach it, and whatever else this plugin needs to do its job.
|
||||
* Nothing in it ever appears on the site.
|
||||
*
|
||||
* ── `built` ──
|
||||
*
|
||||
@@ -27,19 +36,7 @@ import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail
|
||||
* the nav and what actually renders cannot disagree.
|
||||
*/
|
||||
|
||||
export type SectionKey =
|
||||
| 'administration'
|
||||
| 'galleries'
|
||||
| 'media'
|
||||
| 'messages'
|
||||
| 'site'
|
||||
| 'about'
|
||||
| 'home'
|
||||
| 'gallery'
|
||||
| 'drops'
|
||||
| 'collabs'
|
||||
| 'collections'
|
||||
| 'services';
|
||||
export type SectionKey = 'administration' | 'galleries' | 'media' | 'messages' | 'layout' | 'pages';
|
||||
|
||||
export type Section = {
|
||||
key: SectionKey;
|
||||
@@ -75,86 +72,37 @@ export type Section = {
|
||||
|
||||
export const SECTIONS: Section[] = [
|
||||
{
|
||||
key: 'home',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
hint: 'The hero, the intro, and the gallery',
|
||||
path: '/fofos-admin/home',
|
||||
/*
|
||||
The shell every page renders inside, and therefore the first thing to decide.
|
||||
|
||||
First in the nav because it is first in the work: the menu, the footer, the colours and the
|
||||
typefaces are the frame every page is built into, and building pages before the frame exists
|
||||
means building them twice.
|
||||
*/
|
||||
key: 'layout',
|
||||
label: 'Layout',
|
||||
icon: PanelsTopLeft,
|
||||
hint: 'The menu, the footer, the colours and the typefaces',
|
||||
path: '/fofos-admin/layout',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
label: 'About Us',
|
||||
icon: Info,
|
||||
hint: 'The header photograph and the copy',
|
||||
path: '/fofos-admin/about',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'collections',
|
||||
label: 'Collections',
|
||||
icon: Layers,
|
||||
hint: 'Collections, and the items in them',
|
||||
path: '/fofos-admin/collections',
|
||||
/*
|
||||
The pages, built rather than hard-coded. Not started.
|
||||
|
||||
This is what replaced the seven per-page sections that used to be here. Listed while unbuilt on
|
||||
purpose: the nav says what is coming, which is a better answer than a nav that quietly lost seven
|
||||
entries and offers no account of where they went.
|
||||
*/
|
||||
key: 'pages',
|
||||
label: 'Pages',
|
||||
icon: Files,
|
||||
hint: 'Build a page from blocks, and choose where it sits',
|
||||
path: null,
|
||||
built: false,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'gallery',
|
||||
label: 'Gallery',
|
||||
icon: Images,
|
||||
hint: 'Standalone pieces',
|
||||
path: '/fofos-admin/gallery',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'collabs',
|
||||
label: 'Collabs',
|
||||
icon: Handshake,
|
||||
hint: 'Projects with other people',
|
||||
path: '/fofos-admin/collabs',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'drops',
|
||||
label: 'Drops',
|
||||
icon: Sparkles,
|
||||
hint: 'Releases',
|
||||
path: '/fofos-admin/drops',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'services',
|
||||
label: 'Services',
|
||||
icon: Wrench,
|
||||
hint: 'A fixed tree — the words, not the shape',
|
||||
path: '/fofos-admin/services',
|
||||
built: false,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: 'Site settings',
|
||||
icon: Settings,
|
||||
hint: 'Partners, social links, contact details',
|
||||
path: '/fofos-admin/site',
|
||||
built: false,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: 'Messages',
|
||||
icon: Mail,
|
||||
hint: 'What people sent through the contact form',
|
||||
path: '/fofos-admin/messages',
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
/*
|
||||
Galleries — an entity of their own, not a list hanging off a page.
|
||||
@@ -223,7 +171,7 @@ export const SECTIONS: Section[] = [
|
||||
];
|
||||
|
||||
/** Where the detail panel goes when the URL says nothing, or says something it does not recognise. */
|
||||
export const DEFAULT_SECTION: SectionKey = 'messages';
|
||||
export const DEFAULT_SECTION: SectionKey = 'layout';
|
||||
|
||||
export const isSectionKey = (value: string | null): value is SectionKey =>
|
||||
SECTIONS.some((section) => section.key === value);
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user