finish the carousel rename in the panels

Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:06:06 +01:00
co-authored by Claude Opus 5
parent 15042ccc76
commit 6e33e89e2c
15 changed files with 104 additions and 104 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ import { useReady } from './useReady';
* ── Not ready means Administration, whatever the URL says ── * ── Not ready means Administration, whatever the URL says ──
* *
* The gate is `useReady`, shared with the nav so the two cannot disagree. Enforced HERE and not only * The gate is `useReady`, shared with the nav so the two cannot disagree. Enforced HERE and not only
* there, because the nav can only decline to offer a link — `?section=gallery` typed into the address * there, because the nav can only decline to offer a link — `?section=carousels` typed into the address
* bar, or a bookmark from before the connection was forgotten, arrives without going through it. A * bar, or a bookmark from before the connection was forgotten, arrives without going through it. A
* nav that hides a door is a courtesy; this is the lock. * nav that hides a door is a courtesy; this is the lock.
*/ */
+1 -1
View File
@@ -11,7 +11,7 @@ import { useReady } from './useReady';
* *
* ── Why they are in the header and not only in Administration ── * ── Why they are in the header and not only in Administration ──
* *
* Publishing is the last step of every edit — write something on Home, choose a gallery, add a drop, * Publishing is the last step of every edit — write something on Home, choose a carousel, add a drop,
* then make it public. It lived in one card at the bottom of one section, so finishing a change meant * then make it public. It lived in one card at the bottom of one section, so finishing a change meant
* leaving the thing you were working on to go and find the button. A person who does not know the * leaving the thing you were working on to go and find the button. A person who does not know the
* panel simply does not find it, and concludes their edit went live when it did not. * panel simply does not find it, and concludes their edit went live when it did not.
+1 -1
View File
@@ -60,7 +60,7 @@ export type CanvasComponent = {
/* /*
One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader
would then need a cast, and the point of naming them is that the compiler knows `free-text` holds would then need a cast, and the point of naming them is that the compiler knows `free-text` holds
`html` and `gallery` holds `carouselId`. `html` and `carousel` holds `carouselId`.
*/ */
content?: { html?: string; carouselId?: number | null; url?: string; ratio?: number; crop?: string }; content?: { html?: string; carouselId?: number | null; url?: string; ratio?: number; crop?: string };
padding?: Record<string, { x?: number; y?: number }>; padding?: Record<string, { x?: number; y?: number }>;
+8 -8
View File
@@ -8,12 +8,12 @@ import type { FileAction } from 'officerdev';
* ── Why an action and not a task ── * ── Why an action and not a task ──
* *
* A task runs a script as the owner and reports through the job system, which is right for "convert * A task runs a script as the owner and reports through the job system, which is right for "convert
* these" and wrong for this. Creating a gallery is a name to type and a screen to land on — expressing * these" and wrong for this. Creating a carousel is a name to type and a screen to land on — expressing
* it as a job would produce something to watch whose only output is somewhere to go next. * it as a job would produce something to watch whose only output is somewhere to go next.
* *
* ── It only offers itself inside `originals/` ── * ── It only offers itself inside `originals/` ──
* *
* A gallery item is a path relative to that folder, so the action is meaningless anywhere else — and a * A carousel item is a path relative to that folder, so the action is meaningless anywhere else — and a
* menu entry that appears everywhere and works in one place is worse than one that appears where it * menu entry that appears everywhere and works in one place is worse than one that appears where it
* works. The check is on the path rather than on which panel is open, because the same browser is * works. The check is on the path rather than on which panel is open, because the same browser is
* mounted in several places and only the path says where it is pointed. * mounted in several places and only the path says where it is pointed.
@@ -21,7 +21,7 @@ import type { FileAction } from 'officerdev';
const ORIGINALS = '/media/originals'; const ORIGINALS = '/media/originals';
/** Everything under `originals/`, relative to it — which is what a gallery item stores. */ /** Everything under `originals/`, relative to it — which is what a carousel item stores. */
const relativeTo = (path: string): string | null => { const relativeTo = (path: string): string | null => {
const at = path.indexOf(ORIGINALS); const at = path.indexOf(ORIGINALS);
return at === -1 ? null : path.slice(at + ORIGINALS.length).replace(/^\/+/, ''); return at === -1 ? null : path.slice(at + ORIGINALS.length).replace(/^\/+/, '');
@@ -48,12 +48,12 @@ export const fileActions: FileAction[] = [
/* /*
A folder is expanded into the files inside it. A folder is expanded into the files inside it.
What somebody right-clicked is the directory; what a gallery holds is pictures. Without this the What somebody right-clicked is the directory; what a carousel holds is pictures. Without this the
gallery gets one item — the folder — which is what shipped first and is obviously wrong the carousel gets one item — the folder — which is what shipped first and is obviously wrong the
moment you look at it. moment you look at it.
Expanded by the SERVER, because that side already knows which extensions are media and how to Expanded by the SERVER, because that side already knows which extensions are media and how to
walk the tree: it is the same `listOriginals` the sync uses, so a gallery cannot end up holding walk the tree: it is the same `listOriginals` the sync uses, so a carousel cannot end up holding
a file the pipeline would never convert. a file the pipeline would never convert.
*/ */
const chosen = entries.map((entry) => relativeTo(entry.path)).filter((path): path is string => Boolean(path)); const chosen = entries.map((entry) => relativeTo(entry.path)).filter((path): path is string => Boolean(path));
@@ -71,14 +71,14 @@ export const fileActions: FileAction[] = [
.get<{ files?: string[] }>(`/fofos-admin/_config/media/files?path=${encodeURIComponent(chosen[0] ?? '')}`) .get<{ files?: string[] }>(`/fofos-admin/_config/media/files?path=${encodeURIComponent(chosen[0] ?? '')}`)
.catch(() => null); .catch(() => null);
// Falling back to the folder itself would create the wrong gallery quietly. Nothing is better. // Falling back to the folder itself would create the wrong carousel quietly. Nothing is better.
if (!body?.files?.length) return; if (!body?.files?.length) return;
paths = body.files; paths = body.files;
} }
/* /*
Handed over in the URL, and the Carousels panel does the asking. Handed over in the URL, and the Carousels panel does the asking.
The action has no dialog of its own on purpose: naming a gallery, seeing the slug it will get The action has no dialog of its own on purpose: naming a carousel, seeing the slug it will get
and watching the items land belongs on the screen that owns carousels, not in a modal over a and watching the items land belongs on the screen that owns carousels, not in a modal over a
file browser that would then have to explain where the result went. file browser that would then have to explain where the result went.
*/ */
+2 -2
View File
@@ -123,12 +123,12 @@ export const SECTIONS: Section[] = [
Carousels — an entity of their own, not a list hanging off a page. Carousels — an entity of their own, not a list hanging off a page.
Above Media and pinned with it, because the two are the same kind of thing: the raw material the Above Media and pinned with it, because the two are the same kind of thing: the raw material the
site is built from, held on this machine. Media is the files; a gallery is a set of them somebody site is built from, held on this machine. Media is the files; a carousel is a set of them somebody
has chosen and put in an order, which is a decision worth keeping once rather than remaking on has chosen and put in an order, which is a decision worth keeping once rather than remaking on
every page that wants it. every page that wants it.
`requires: 'connection'`, unlike Media beside it, and the difference is real rather than a `requires: 'connection'`, unlike Media beside it, and the difference is real rather than a
copy-paste. Media is a file browser over a folder on this disk and needs nothing else. A gallery copy-paste. Media is a file browser over a folder on this disk and needs nothing else. A carousel
is a ROW in the site's database — reading or writing one goes through the site's admin API, so is a ROW in the site's database — reading or writing one goes through the site's admin API, so
this section is useless without the development server answering, and opening it there would be this section is useless without the development server answering, and opening it there would be
a screen where every action fails. a screen where every action fails.
+4 -4
View File
@@ -63,7 +63,7 @@ type Choice = {
* *
* Named rather than omitted, and the ones named are things the site already HAS — media, video and * Named rather than omitted, and the ones named are things the site already HAS — media, video and
* carousels all exist as concepts here. That is the difference between saying what is coming and * carousels all exist as concepts here. That is the difference between saying what is coming and
* inventing a roadmap. Gallery stopped being one of them on 2026-08-31: the set, its ordering and the * inventing a roadmap. The carousel stopped being one of them on 2026-08-31: the set, its ordering and
* carousel that draws it were all already built, so the block is a reference to one. * carousel that draws it were all already built, so the block is a reference to one.
*/ */
export const CHOICES: Choice[] = [ export const CHOICES: Choice[] = [
@@ -73,7 +73,7 @@ export const CHOICES: Choice[] = [
Image and Video were two rows — one of them greyed out as `soon` — and the distinction was never Image and Video were two rows — one of them greyed out as `soon` — and the distinction was never
the editor's to make: the path names a real file and what that file IS is a fact about it. So the the editor's to make: the path names a real file and what that file IS is a fact about it. So the
picker asks for a file and the site works out the rest, which is also what a gallery item has picker asks for a file and the site works out the rest, which is also what a carousel item has
always done. always done.
*/ */
{ kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image }, { kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image },
@@ -104,11 +104,11 @@ export const CHOICES: Choice[] = [
"it" for that reason. What differs between the sections is a path and a few spec labels, and both "it" for that reason. What differs between the sections is a path and a few spec labels, and both
travel with the subject. travel with the subject.
*/ */
{ kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image }, { kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its carousel, large', Icon: Image },
{ kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading }, { kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading },
{ kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type }, { kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler }, { kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler },
{ kind: 'subject-carousel', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images }, { kind: 'subject-carousel', label: 'Its photographs', hint: 'The carousel it points at', Icon: Images },
]; ];
export const AddComponentDialog = ({ export const AddComponentDialog = ({
+1 -1
View File
@@ -12,7 +12,7 @@ import { useDevice } from '../DeviceToggle';
* *
* ── Why it is not inside the free text ── * ── Why it is not inside the free text ──
* *
* Typing a heading into the text works for one kind of block and no others. A gallery, a video and a * Typing a heading into the text works for one kind of block and no others. A carousel, a video and a
* partner row all want to announce themselves and have nowhere to put the words — so the heading is a * partner row all want to announce themselves and have nowhere to put the words — so the heading is a
* property of the BLOCK rather than of its contents, and every kind gets one. * property of the BLOCK rather than of its contents, and every kind gets one.
* *
+15 -15
View File
@@ -8,15 +8,15 @@ import { readableError } from '../errors';
import { thumbUrl } from '../thumbnail'; import { thumbUrl } from '../thumbnail';
/** /**
* One gallery's contents: what is in it, in what order, and what each picture is called. * One carousel's contents: what is in it, in what order, and what each picture is called.
* *
* ── Why it is here and not a link to Carousels ── * ── Why it is here and not a link to Carousels ──
* *
* A gallery block on a page IS a gallery, and being sent to another section to reorder it means * A carousel block on a page IS a carousel, and being sent to another section to reorder it means
* losing the page you were arranging it for. The set is a thing of its own — that is why it has its * losing the page you were arranging it for. The set is a thing of its own — that is why it has its
* own section — but editing it is something you do while looking at where it appears. * own section — but editing it is something you do while looking at where it appears.
* *
* ── It reads the gallery itself ── * ── It reads the carousel itself ──
* *
* `GET /carousels/:id`, rather than being handed one. The block knows an id and nothing else, and a * `GET /carousels/:id`, rather than being handed one. The block knows an id and nothing else, and a
* component that fetches what it needs can be dropped anywhere something knows an id. * component that fetches what it needs can be dropped anywhere something knows an id.
@@ -29,21 +29,21 @@ import { thumbUrl } from '../thumbnail';
*/ */
type Item = { id: number; url: string; alt: string | null }; type Item = { id: number; url: string; alt: string | null };
type Gallery = { id: number; name: string; slug: string; items: Item[] }; type Carousel = { id: number; name: string; slug: string; items: Item[] };
export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; onChanged?: () => void }) => { export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; onChanged?: () => void }) => {
const client = useClient(); const client = useClient();
const clientRef = useRef(client); const clientRef = useRef(client);
clientRef.current = client; clientRef.current = client;
const [gallery, setCarousel] = useState<Gallery | null>(null); const [carousel, setCarousel] = useState<Carousel | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setCarousel((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/carousels/${carouselId}`)).data); setCarousel((await clientRef.current.get<{ data: Carousel }>(`/fofos-admin/carousels/${carouselId}`)).data);
} catch (failure) { } catch (failure) {
setError(readableError(failure)); setError(readableError(failure));
} }
@@ -60,7 +60,7 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
try { try {
await call(); await call();
await load(); await load();
// The page draws this gallery, so it is now showing the old one. // The page draws this carousel, so it is now showing the old one.
onChanged?.(); onChanged?.();
} catch (failure) { } catch (failure) {
setError(readableError(failure)); setError(readableError(failure));
@@ -83,19 +83,19 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
const orderedRef = useRef<Item[] | null>(null); const orderedRef = useRef<Item[] | null>(null);
const moveOver = (event: React.DragEvent, overId: number) => { const moveOver = (event: React.DragEvent, overId: number) => {
if (dragged === null || !gallery) return; if (dragged === null || !carousel) return;
// Without this the drop is refused and the browser animates the row back to where it started. // Without this the drop is refused and the browser animates the row back to where it started.
event.preventDefault(); event.preventDefault();
if (dragged === overId) return; if (dragged === overId) return;
const next = [...gallery.items]; const next = [...carousel.items];
const from = next.findIndex((one) => one.id === dragged); const from = next.findIndex((one) => one.id === dragged);
const to = next.findIndex((one) => one.id === overId); const to = next.findIndex((one) => one.id === overId);
if (from < 0 || to < 0) return; if (from < 0 || to < 0) return;
next.splice(to, 0, next.splice(from, 1)[0]!); next.splice(to, 0, next.splice(from, 1)[0]!);
orderedRef.current = next; orderedRef.current = next;
setCarousel({ ...gallery, items: next }); setCarousel({ ...carousel, items: next });
}; };
const commitOrder = () => { const commitOrder = () => {
@@ -110,19 +110,19 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
); );
}; };
if (!gallery) return <p className="text-xs text-muted-foreground">{error ?? 'Loading…'}</p>; if (!carousel) return <p className="text-xs text-muted-foreground">{error ?? 'Loading…'}</p>;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium">{gallery.name}</span> <span className="min-w-0 flex-1 truncate text-xs font-medium">{carousel.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground"> <span className="shrink-0 text-[10px] text-muted-foreground">
{gallery.items.length} item{gallery.items.length === 1 ? '' : 's'} {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span> </span>
</div> </div>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{gallery.items.map((item) => ( {carousel.items.map((item) => (
<li <li
key={item.id} key={item.id}
/* The row is the drop target; the grip is what drags. See the note on the handler. */ /* The row is the drop target; the grip is what drags. See the note on the handler. */
@@ -213,7 +213,7 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
setPicking(false); setPicking(false);
void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] })); void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] }));
}} }}
title="Add to this gallery" title="Add to this carousel"
/> />
</div> </div>
); );
+30 -30
View File
@@ -11,7 +11,7 @@ import { CarouselItems } from './CarouselItems';
/** /**
* Carousels — named, ordered sets of media, reusable anywhere on the site. * Carousels — named, ordered sets of media, reusable anywhere on the site.
* *
* ── Why a gallery is not a list on a page ── * ── Why a carousel is not a list on a page ──
* *
* The home page had one, as rows hanging off its own content. That works for exactly one. The moment a * The home page had one, as rows hanging off its own content. That works for exactly one. The moment a
* second page wants the same photographs in the same order, a list-per-page means choosing them again * second page wants the same photographs in the same order, a list-per-page means choosing them again
@@ -19,26 +19,26 @@ import { CarouselItems } from './CarouselItems';
* *
* ── The server owns the shape ── * ── The server owns the shape ──
* *
* Every write answers with the whole gallery and this never patches its own state from what it sent. * Every write answers with the whole carousel and this never patches its own state from what it sent.
* That matters most here: reordering renumbers everything, adding appends after whatever was there, * That matters most here: reordering renumbers everything, adding appends after whatever was there,
* and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to * and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to
* notice. * notice.
*/ */
type Item = { id: number; url: string; alt: string | null; sort_order: number }; type Item = { id: number; url: string; alt: string | null; sort_order: number };
type Gallery = { id: number; name: string; slug: string; items: Item[] }; type Carousel = { id: number; name: string; slug: string; items: Item[] };
export const CarouselsPanel = () => { export const CarouselsPanel = () => {
const client = useClient(); const client = useClient();
const clientRef = useRef(client); const clientRef = useRef(client);
clientRef.current = client; clientRef.current = client;
const [carousels, setCarousels] = useState<Gallery[] | null>(null); const [carousels, setCarousels] = useState<Carousel[] | null>(null);
const [openId, setOpenId] = useState<number | null>(null); const [openId, setOpenId] = useState<number | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
/** Paths handed over by the context menu, waiting for a name. */ /** Paths handed over by the context menu, waiting for a name. */
const [pending, setPending] = useState<string[]>([]); const [pending, setPending] = useState<string[]>([]);
/** The gallery being renamed, and the name being typed into it. Null when nobody is renaming. */ /** The carousel being renamed, and the name being typed into it. Null when nobody is renaming. */
const [renaming, setRenaming] = useState<{ id: number; name: string } | null>(null); const [renaming, setRenaming] = useState<{ id: number; name: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -46,7 +46,7 @@ export const CarouselsPanel = () => {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/carousels'); const response = await clientRef.current.get<{ data: Carousel[] }>('/fofos-admin/carousels');
setCarousels(response.data); setCarousels(response.data);
} catch (failure) { } catch (failure) {
setError(readableError(failure)); setError(readableError(failure));
@@ -59,7 +59,7 @@ export const CarouselsPanel = () => {
}, [load]); }, [load]);
/* /*
The selected gallery's contents, handed to the editor column. The selected carousel's contents, handed to the editor column.
Announced rather than rendered, because that panel is a SIBLING — see useEditorSlot. `onChanged` Announced rather than rendered, because that panel is a SIBLING — see useEditorSlot. `onChanged`
reloads the list here, so the item count beside the name is never one behind what is in it. reloads the list here, so the item count beside the name is never one behind what is in it.
@@ -77,16 +77,16 @@ export const CarouselsPanel = () => {
); );
}, [openId, load]); }, [openId, load]);
// Cleared on unmount, or the panel goes on offering a gallery over somebody else's screen. // Cleared on unmount, or the panel goes on offering a carousel over somebody else's screen.
useEffect(() => () => setEditorRef.current(null), []); useEffect(() => () => setEditorRef.current(null), []);
/* /*
Arriving from "Create Gallery" in the file browser. Arriving from "Carousel from folder" in the file browser.
The action puts the chosen paths in the URL and sends you here rather than opening its own dialog: The action puts the chosen paths in the URL and sends you here rather than opening its own dialog:
naming a gallery, seeing the slug it gets and watching the items land belongs on the screen that naming a carousel, seeing the slug it gets and watching the items land belongs on the screen that
owns carousels. Consumed once and removed from the URL — a `?from=` left behind would recreate the owns carousels. Consumed once and removed from the URL — a `?from=` left behind would recreate the
gallery on every refresh, which somebody discovers as four copies. carousel on every refresh, which somebody discovers as four copies.
*/ */
const arriving = params.get('from'); const arriving = params.get('from');
useEffect(() => { useEffect(() => {
@@ -124,13 +124,13 @@ export const CarouselsPanel = () => {
/* /*
Renaming changes the NAME and leaves the slug exactly where it is. Renaming changes the NAME and leaves the slug exactly where it is.
Everything that points at a gallery points at its id — `collections.carousel_id`, and a gallery Everything that points at a carousel points at its id — `collections.carousel_id`, and a carousel
block's `content.carouselId`. Nothing renders through the slug, so a rename cannot break a reference block's `content.carouselId`. Nothing renders through the slug, so a rename cannot break a reference
today. It is left alone anyway, because a slug is an ADDRESS: it can end up in a link somebody today. It is left alone anyway, because a slug is an ADDRESS: it can end up in a link somebody
sent, and the day something does resolve by it, a label having quietly moved it would be a very sent, and the day something does resolve by it, a label having quietly moved it would be a very
hard bug to find. hard bug to find.
So the two are separable and this only writes one of them. A gallery whose slug is genuinely wrong So the two are separable and this only writes one of them. A carousel whose slug is genuinely wrong
is a different act with different consequences, and it should look like one. is a different act with different consequences, and it should look like one.
*/ */
const rename = () => const rename = () =>
@@ -143,7 +143,7 @@ export const CarouselsPanel = () => {
const create = () => const create = () =>
send('create', async () => { send('create', async () => {
// `urls` in the same request, so creating from a selection is one action — splitting it would // `urls` in the same request, so creating from a selection is one action — splitting it would
// leave an empty gallery behind whenever the second call failed. // leave an empty carousel behind whenever the second call failed.
await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending }); await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending });
setName(''); setName('');
setPending([]); setPending([]);
@@ -170,7 +170,7 @@ export const CarouselsPanel = () => {
autoFocus={pending.length > 0} autoFocus={pending.length > 0}
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="Name a new gallery" placeholder="Name a new carousel"
className="min-w-0 flex-1 text-xs" className="min-w-0 flex-1 text-xs"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && name.trim()) void create(); if (event.key === 'Enter' && name.trim()) void create();
@@ -192,23 +192,23 @@ export const CarouselsPanel = () => {
{/* {/*
The list, and only the list. The list, and only the list.
The contents of a gallery — its pictures, their order, their captions — are announced to the The contents of a carousel — its pictures, their order, their captions — are announced to the
editor column and drawn by `CarouselItems`, which is the same component a gallery BLOCK on a editor column and drawn by `CarouselItems`, which is the same component a carousel BLOCK on a
page uses. Two copies of that list existed for a day and this is the one that went. page uses. Two copies of that list existed for a day and this is the one that went.
So this panel is what a master list should be: which carousels there are, making one, deleting So this panel is what a master list should be: which carousels there are, making one, deleting
one. What is inside the selected one is a different question, answered beside it. one. What is inside the selected one is a different question, answered beside it.
*/} */}
{carousels.map((gallery) => { {carousels.map((carousel) => {
const open = openId === gallery.id; const open = openId === carousel.id;
return ( return (
<section <section
key={gallery.id} key={carousel.id}
className={`flex items-center gap-3 rounded-lg border p-4 ${ className={`flex items-center gap-3 rounded-lg border p-4 ${
open ? 'border-primary/60 bg-muted/40' : 'border-border' open ? 'border-primary/60 bg-muted/40' : 'border-border'
}`} }`}
> >
{renaming?.id === gallery.id ? ( {renaming?.id === carousel.id ? (
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<Images className="size-4 shrink-0" /> <Images className="size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
@@ -216,7 +216,7 @@ export const CarouselsPanel = () => {
autoFocus autoFocus
value={renaming.name} value={renaming.name}
className="h-7 text-sm" className="h-7 text-sm"
onChange={(event) => setRenaming({ id: gallery.id, name: event.target.value })} onChange={(event) => setRenaming({ id: carousel.id, name: event.target.value })}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && renaming.name.trim()) void rename(); if (event.key === 'Enter' && renaming.name.trim()) void rename();
if (event.key === 'Escape') setRenaming(null); if (event.key === 'Escape') setRenaming(null);
@@ -225,7 +225,7 @@ export const CarouselsPanel = () => {
{/* Said while renaming, not afterwards: somebody about to change a name is exactly {/* Said while renaming, not afterwards: somebody about to change a name is exactly
who wonders whether the address moves with it. */} who wonders whether the address moves with it. */}
<span className="block truncate font-mono text-xs text-muted-foreground/70"> <span className="block truncate font-mono text-xs text-muted-foreground/70">
{gallery.slug} · the address does not change {carousel.slug} · the address does not change
</span> </span>
</div> </div>
<Button <Button
@@ -252,24 +252,24 @@ export const CarouselsPanel = () => {
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left" className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setOpenId(open ? null : gallery.id)} onClick={() => setOpenId(open ? null : carousel.id)}
> >
<Images className="size-4 shrink-0" /> <Images className="size-4 shrink-0" />
<span className="min-w-0 space-y-1"> <span className="min-w-0 space-y-1">
<span className="block truncate text-sm font-medium">{gallery.name}</span> <span className="block truncate text-sm font-medium">{carousel.name}</span>
<span className="block truncate font-mono text-xs text-muted-foreground/70"> <span className="block truncate font-mono text-xs text-muted-foreground/70">
{gallery.slug} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'} {carousel.slug} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span> </span>
</span> </span>
</button> </button>
)} )}
{renaming?.id !== gallery.id && ( {renaming?.id !== carousel.id && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="shrink-0" className="shrink-0"
onClick={() => setRenaming({ id: gallery.id, name: gallery.name })} onClick={() => setRenaming({ id: carousel.id, name: carousel.name })}
disabled={busy !== null} disabled={busy !== null}
title="Rename" title="Rename"
> >
@@ -282,8 +282,8 @@ export const CarouselsPanel = () => {
size="sm" size="sm"
className="shrink-0" className="shrink-0"
onClick={() => onClick={() =>
void send(`gallery:${gallery.id}`, () => void send(`carousel:${carousel.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${gallery.id}`), clientRef.current.delete(`/fofos-admin/carousels/${carousel.id}`),
) )
} }
disabled={busy !== null} disabled={busy !== null}
+19 -19
View File
@@ -10,7 +10,7 @@ import { MediaPicker } from '../MediaPicker';
import { useAutosave } from '../useAutosave'; import { useAutosave } from '../useAutosave';
/** /**
* The Home page: a hero, an intro, and an ordered gallery. * The Home page: a hero, an intro, and an ordered carousel.
* *
* ── It edits DEVELOPMENT, and that is the whole model ── * ── It edits DEVELOPMENT, and that is the whole model ──
* *
@@ -21,18 +21,18 @@ import { useAutosave } from '../useAutosave';
* *
* ── The server owns the shape, and every write returns it ── * ── The server owns the shape, and every write returns it ──
* *
* `PATCH /home` and every gallery route answer with the WHOLE payload, so this never patches its own * `PATCH /home` and every carousel route answer with the WHOLE payload, so this never patches its own
* state from what it sent. That matters more than it looks: the server clears the hero crop when the * state from what it sent. That matters more than it looks: the server clears the hero crop when the
* hero changes, and a client that kept its own copy would go on showing a framing the server had * hero changes, and a client that kept its own copy would go on showing a framing the server had
* already discarded. * already discarded.
* *
* ── Ordering is a list, not a field ── * ── Ordering is a list, not a field ──
* *
* `PUT /gallery/order` takes every id in the order they should be. So moving one item sends all of * `PUT /carousel/order` takes every id in the order they should be. So moving one item sends all of
* them, which is what makes it impossible to end up with two things claiming the same position. * them, which is what makes it impossible to end up with two things claiming the same position.
*/ */
type GalleryItem = { type CarouselItem = {
id: number; id: number;
kind: string; kind: string;
url: string; url: string;
@@ -45,13 +45,13 @@ type GalleryItem = {
type Home = { type Home = {
hero: { url: string | null; crop: string | null; fit: string | null }; hero: { url: string | null; crop: string | null; fit: string | null };
intro_text: string | null; intro_text: string | null;
/** The OLD per-page list. Still returned by the API, and only rendered while no gallery is chosen. */ /** The OLD per-page list. Still returned by the API, and only rendered while no carousel is chosen. */
gallery: GalleryItem[]; carousel: CarouselItem[];
/** Which gallery the carousel shows. Null means the old list above still applies. */ /** Which carousel the carousel shows. Null means the old list above still applies. */
carousel_id: number | null; carousel_id: number | null;
}; };
type Gallery = { id: number; name: string; slug: string; items: unknown[] }; type Carousel = { id: number; name: string; slug: string; items: unknown[] };
export const HomePanel = () => { export const HomePanel = () => {
const client = useClient(); const client = useClient();
@@ -65,7 +65,7 @@ export const HomePanel = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash(); const [saved, flashSaved] = useFlash();
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const [carousels, setCarousels] = useState<Gallery[]>([]); const [carousels, setCarousels] = useState<Carousel[]>([]);
/** Every write answers with the whole payload, so this is the only place state is set. */ /** Every write answers with the whole payload, so this is the only place state is set. */
const apply = useCallback((data: Home) => { const apply = useCallback((data: Home) => {
@@ -91,7 +91,7 @@ export const HomePanel = () => {
// home payload — a copy there would go stale the moment somebody edited one. // home payload — a copy there would go stale the moment somebody edited one.
useEffect(() => { useEffect(() => {
void clientRef.current void clientRef.current
.get<{ data: Gallery[] }>('/fofos-admin/carousels') .get<{ data: Carousel[] }>('/fofos-admin/carousels')
.then((response) => setCarousels(response.data)) .then((response) => setCarousels(response.data))
.catch(() => setCarousels([])); .catch(() => setCarousels([]));
}, []); }, []);
@@ -119,7 +119,7 @@ export const HomePanel = () => {
/* /*
One Save for everything this page holds in a field. One Save for everything this page holds in a field.
The gallery choice is NOT part of it: picking a gallery from the list writes immediately, because a The carousel choice is NOT part of it: picking a carousel from the list writes immediately, because a
<select> that needs a second press to mean anything is a control people believe they have already <select> that needs a second press to mean anything is a control people believe they have already
used. The two fields below are typed, and typing is what Save is for. used. The two fields below are typed, and typing is what Save is for.
*/ */
@@ -211,7 +211,7 @@ export const HomePanel = () => {
</section> </section>
<section className="flex flex-col gap-3 rounded-lg border border-border p-4"> <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Gallery</h3> <h3 className="text-sm font-medium">Carousel</h3>
{/* {/*
A picker, not a list. A picker, not a list.
@@ -233,19 +233,19 @@ export const HomePanel = () => {
className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs" className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs"
> >
<option value="">None</option> <option value="">None</option>
{carousels.map((gallery) => ( {carousels.map((carousel) => (
<option key={gallery.id} value={gallery.id}> <option key={carousel.id} value={carousel.id}>
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'} {carousel.name} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</option> </option>
))} ))}
</select> </select>
{/* Only while it is true: the old rows are what renders until a gallery is chosen, and saying so {/* Only while it is true: the old rows are what renders until a carousel is chosen, and saying so
beats a carousel that appears to come from nowhere. */} beats a carousel that appears to come from nowhere. */}
{home.carousel_id === null && home.gallery.length > 0 && ( {home.carousel_id === null && home.carousel.length > 0 && (
<p className="text-xs text-muted-foreground/70"> <p className="text-xs text-muted-foreground/70">
Showing {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old Showing {home.carousel.length} item{home.carousel.length === 1 ? '' : 's'} from the old
per-page list. Choosing a gallery replaces it. per-page list. Choosing a carousel replaces it.
</p> </p>
)} )}
</section> </section>
+9 -9
View File
@@ -162,7 +162,7 @@ export const PageBuilderPanel = () => {
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null); const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
/** The component being written, and where a new one would go. Null when the dialog is shut. */ /** The component being written, and where a new one would go. Null when the dialog is shut. */
/* /*
Which gallery a block shows, while it is being chosen. Which carousel a block shows, while it is being chosen.
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
there is nothing to draft and nothing to cancel out of afterwards. `carouselId` rides along so there is nothing to draft and nothing to cancel out of afterwards. `carouselId` rides along so
@@ -274,7 +274,7 @@ export const PageBuilderPanel = () => {
The effect below cannot do that job: bumping `rendered` runs it immediately, while `frameDoc` still The effect below cannot do that job: bumping `rendered` runs it immediately, while `frameDoc` still
points at the outgoing document — so it scrolled the page that was about to be replaced, and the points at the outgoing document — so it scrolled the page that was about to be replaced, and the
new one arrived at the top. Every write from the editor did that: reorder a gallery, lose your new one arrived at the top. Every write from the editor did that: reorder a carousel, lose your
place. place.
*/ */
const partRef = useRef<string | null>(part); const partRef = useRef<string | null>(part);
@@ -474,7 +474,7 @@ export const PageBuilderPanel = () => {
with one outcome, so there is nothing to draft and nothing to cancel out of afterwards. with one outcome, so there is nothing to draft and nothing to cancel out of afterwards.
Photograph or video is not asked. The file already knows, and the site reads it off the extension Photograph or video is not asked. The file already knows, and the site reads it off the extension
the way a gallery item always has — a kind stored here would be a second answer able to disagree. the way a carousel item always has — a kind stored here would be a second answer able to disagree.
*/ */
const saveVideo = async (url: string) => { const saveVideo = async (url: string) => {
if (!page || !picking) return; if (!page || !picking) return;
@@ -494,7 +494,7 @@ export const PageBuilderPanel = () => {
}; };
/* /*
A gallery block is a REFERENCE, saved as soon as the set is chosen — see CarouselPicker. A carousel block is a REFERENCE, saved as soon as the set is chosen — see CarouselPicker.
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
value. The two are left as two functions rather than one generic saver, because what they store value. The two are left as two functions rather than one generic saver, because what they store
@@ -589,7 +589,7 @@ export const PageBuilderPanel = () => {
return; return;
} }
if (component.kind === 'gallery') { if (component.kind === 'carousel') {
const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId; const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId;
setChoosingCarousel({ setChoosingCarousel({
id: component.id, id: component.id,
@@ -793,7 +793,7 @@ export const PageBuilderPanel = () => {
*/} */}
{block.kind === 'partners' && <PartnersEditor onChanged={() => setRendered((n) => n + 1)} />} {block.kind === 'partners' && <PartnersEditor onChanged={() => setRendered((n) => n + 1)} />}
{block.kind === 'gallery' && ( {block.kind === 'carousel' && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
size="sm" size="sm"
@@ -801,13 +801,13 @@ export const PageBuilderPanel = () => {
className="h-8 self-start text-xs" className="h-8 self-start text-xs"
onClick={() => handlersRef.current.editComponent(block)} onClick={() => handlersRef.current.editComponent(block)}
> >
Choose gallery… Choose carousel…
</Button> </Button>
{/* {/*
The gallery's own contents, here rather than a link to the Carousels section. The carousel's own contents, here rather than a link to the Carousels section.
A gallery block on a page IS a gallery, and being sent elsewhere to reorder it means A carousel block on a page IS a carousel, and being sent elsewhere to reorder it means
losing the page you were arranging it for. `onChanged` reloads the frame, because the losing the page you were arranging it for. `onChanged` reloads the frame, because the
page draws this set and has just been given a different one. page draws this set and has just been given a different one.
*/} */}
+1 -1
View File
@@ -22,7 +22,7 @@ import { BandBlocks } from './BandBlocks';
* ── The gesture is the one used everywhere else ── * ── The gesture is the one used everywhere else ──
* *
* Grip on the left, the row is the drop target, the list rearranges as the pointer crosses it, one * Grip on the left, the row is the drop target, the list rearranges as the pointer crosses it, one
* write at the end carrying every id. Pages, gallery items and partners all do this; a fourth way to * write at the end carrying every id. Pages, carousel items and partners all do this; a fourth way to
* reorder something would be a fourth thing to learn. * reorder something would be a fourth thing to learn.
*/ */
export const PageStructure = ({ export const PageStructure = ({
+1 -1
View File
@@ -13,7 +13,7 @@ import type { SubjectRecord, SubjectSection } from './subjects';
* The fields below the description come from the section — see subjects.ts. Everything above them is * The fields below the description come from the section — see subjects.ts. Everything above them is
* shared because it is the same in both: what it is called, and where it lives. * shared because it is the same in both: what it is called, and where it lives.
* *
* The gallery is not here. It is chosen beside the photographs it puts on screen, in the panel, where * The carousel is not here. It is chosen beside the photographs it puts on screen, in the panel, where
* the consequence of choosing it is visible — a dropdown in a column of text fields would be the one * the consequence of choosing it is visible — a dropdown in a column of text fields would be the one
* control whose effect you cannot see. * control whose effect you cannot see.
* *
+9 -9
View File
@@ -25,7 +25,7 @@ import type { SubjectPhoto, SubjectRecord, SubjectSection } from './subjects';
* *
* ── The grid is READ-ONLY ── * ── The grid is READ-ONLY ──
* *
* The photographs belong to the gallery, and so does their order — including which one leads, which * The photographs belong to the carousel, and so does their order — including which one leads, which
* is simply the first. Making them look pressable here would promise a control that lives in * is simply the first. Making them look pressable here would promise a control that lives in
* Carousels. * Carousels.
*/ */
@@ -85,7 +85,7 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
); );
}, [records, selectedId, setParams, section.param]); }, [records, selectedId, setParams, section.param]);
// The gallery's photographs, re-read when the selection moves. Null while nothing is chosen, so the // The carousel's photographs, re-read when the selection moves. Null while nothing is chosen, so the
// empty state and "still loading" are not the same thing. // empty state and "still loading" are not the same thing.
useEffect(() => { useEffect(() => {
if (!selectedId || !selected) { if (!selectedId || !selected) {
@@ -230,10 +230,10 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-border pb-3"> <div className="mb-4 flex flex-wrap items-center gap-2 border-b border-border pb-3">
<Button size="sm" variant="outline" className="h-8 gap-1.5 text-xs" onClick={() => setPicking(true)}> <Button size="sm" variant="outline" className="h-8 gap-1.5 text-xs" onClick={() => setPicking(true)}>
<Images className="size-3.5" /> <Images className="size-3.5" />
{selected.carousel_id ? 'Change gallery' : 'Choose a gallery'} {selected.carousel_id ? 'Change carousel' : 'Choose a carousel'}
</Button> </Button>
<span className="min-w-0 flex-1" /> <span className="min-w-0 flex-1" />
{/* Deleting removes the ROW, never the photographs — they belong to a gallery, which {/* Deleting removes the ROW, never the photographs — they belong to a carousel, which
belongs to Carousels. Said on the button so nobody has to find out. */} belongs to Carousels. Said on the button so nobody has to find out. */}
<Button <Button
size="sm" size="sm"
@@ -241,7 +241,7 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
className="h-8 gap-1.5 text-xs hover:text-destructive" className="h-8 gap-1.5 text-xs hover:text-destructive"
disabled={busy} disabled={busy}
onClick={() => void remove()} onClick={() => void remove()}
title={`Delete this ${section.noun}. The gallery and its photographs stay.`} title={`Delete this ${section.noun}. The carousel and its photographs stay.`}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
Delete Delete
@@ -253,20 +253,20 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
<p className="text-xs text-muted-foreground">Choose a {section.noun}, or make one.</p> <p className="text-xs text-muted-foreground">Choose a {section.noun}, or make one.</p>
) : !selected.carousel_id ? ( ) : !selected.carousel_id ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No gallery yet. Choose one and its photographs appear here. No carousel yet. Choose one and its photographs appear here.
</p> </p>
) : photos === null ? ( ) : photos === null ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground"> <p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Reading the gallery… <Loader2 className="size-3 animate-spin" /> Reading the carousel…
</p> </p>
) : photos.length === 0 ? ( ) : photos.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
That gallery is empty. Add photographs to it in Carousels. That carousel is empty. Add photographs to it in Carousels.
</p> </p>
) : ( ) : (
<> <>
<p className="mb-3 text-xs text-muted-foreground"> <p className="mb-3 text-xs text-muted-foreground">
{photos.length} photographs, in the gallery&rsquo;s order &mdash; the first one leads. {photos.length} photographs, in the carousel&rsquo;s order &mdash; the first one leads.
Reorder them in Carousels. Reorder them in Carousels.
</p> </p>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3"> <ul className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
+2 -2
View File
@@ -6,7 +6,7 @@ import { Boxes, Shapes } from 'lucide-react';
* *
* ── Why one shape ── * ── Why one shape ──
* *
* They are the same object. A title, an address, some words, a gallery, and a table of properties; * They are the same object. A title, an address, some words, a carousel, and a table of properties;
* the sections differ in what they GROUP, not in how one is edited. Copying the panel would be three * the sections differ in what they GROUP, not in how one is edited. Copying the panel would be three
* hundred lines duplicated for an icon and a noun, and the copies would drift the first time one of * hundred lines duplicated for an icon and a noun, and the copies would drift the first time one of
* them got a fix. * them got a fix.
@@ -25,7 +25,7 @@ import { Boxes, Shapes } from 'lucide-react';
/** What every subject row has. Snake case: it is the wire, not our shape. */ /** What every subject row has. Snake case: it is the wire, not our shape. */
export type SubjectRecord = { export type SubjectRecord = {
id: number; id: number;
/** The gallery holding its media, or null while nobody has chosen one. */ /** The carousel holding its media, or null while nobody has chosen one. */
carousel_id: number | null; carousel_id: number | null;
title: string; title: string;
slug: string | null; slug: string | null;