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 ──
*
* 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
* 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 ──
*
* 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
* 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.
+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
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 };
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 ──
*
* 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 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
* 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.
@@ -21,7 +21,7 @@ import type { FileAction } from 'officerdev';
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 at = path.indexOf(ORIGINALS);
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.
What somebody right-clicked is the directory; what a gallery holds is pictures. Without this the
gallery gets one item — the folder — which is what shipped first and is obviously wrong the
What somebody right-clicked is the directory; what a carousel holds is pictures. Without this the
carousel gets one item — the folder — which is what shipped first and is obviously wrong the
moment you look at it.
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.
*/
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] ?? '')}`)
.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;
paths = body.files;
}
/*
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
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.
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
every page that wants it.
`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
this section is useless without the development server answering, and opening it there would be
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
* 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.
*/
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
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.
*/
{ 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
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-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-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 = ({
+1 -1
View File
@@ -12,7 +12,7 @@ import { useDevice } from '../DeviceToggle';
*
* ── 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
* 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';
/**
* 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 ──
*
* 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
* 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
* 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 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 }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [gallery, setCarousel] = useState<Gallery | null>(null);
const [carousel, setCarousel] = useState<Carousel | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
const load = useCallback(async () => {
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) {
setError(readableError(failure));
}
@@ -60,7 +60,7 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
try {
await call();
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?.();
} catch (failure) {
setError(readableError(failure));
@@ -83,19 +83,19 @@ export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; o
const orderedRef = useRef<Item[] | null>(null);
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.
event.preventDefault();
if (dragged === overId) return;
const next = [...gallery.items];
const next = [...carousel.items];
const from = next.findIndex((one) => one.id === dragged);
const to = next.findIndex((one) => one.id === overId);
if (from < 0 || to < 0) return;
next.splice(to, 0, next.splice(from, 1)[0]!);
orderedRef.current = next;
setCarousel({ ...gallery, items: next });
setCarousel({ ...carousel, items: next });
};
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 (
<div className="flex flex-col 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">
{gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
{carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span>
</div>
<ul className="flex flex-col gap-1">
{gallery.items.map((item) => (
{carousel.items.map((item) => (
<li
key={item.id}
/* 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);
void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] }));
}}
title="Add to this gallery"
title="Add to this carousel"
/>
</div>
);
+30 -30
View File
@@ -11,7 +11,7 @@ import { CarouselItems } from './CarouselItems';
/**
* 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
* 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 ──
*
* 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,
* and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to
* notice.
*/
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 = () => {
const client = useClient();
const clientRef = useRef(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 [name, setName] = useState('');
/** Paths handed over by the context menu, waiting for a name. */
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 [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +46,7 @@ export const CarouselsPanel = () => {
const load = useCallback(async () => {
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);
} catch (failure) {
setError(readableError(failure));
@@ -59,7 +59,7 @@ export const CarouselsPanel = () => {
}, [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`
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]);
// 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), []);
/*
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:
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
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');
useEffect(() => {
@@ -124,13 +124,13 @@ export const CarouselsPanel = () => {
/*
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
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
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.
*/
const rename = () =>
@@ -143,7 +143,7 @@ export const CarouselsPanel = () => {
const create = () =>
send('create', async () => {
// `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 });
setName('');
setPending([]);
@@ -170,7 +170,7 @@ export const CarouselsPanel = () => {
autoFocus={pending.length > 0}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Name a new gallery"
placeholder="Name a new carousel"
className="min-w-0 flex-1 text-xs"
onKeyDown={(event) => {
if (event.key === 'Enter' && name.trim()) void create();
@@ -192,23 +192,23 @@ export const CarouselsPanel = () => {
{/*
The list, and only the list.
The contents of a gallery — 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
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 carousel BLOCK on a
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
one. What is inside the selected one is a different question, answered beside it.
*/}
{carousels.map((gallery) => {
const open = openId === gallery.id;
{carousels.map((carousel) => {
const open = openId === carousel.id;
return (
<section
key={gallery.id}
key={carousel.id}
className={`flex items-center gap-3 rounded-lg border p-4 ${
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">
<Images className="size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
@@ -216,7 +216,7 @@ export const CarouselsPanel = () => {
autoFocus
value={renaming.name}
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) => {
if (event.key === 'Enter' && renaming.name.trim()) void rename();
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
who wonders whether the address moves with it. */}
<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>
</div>
<Button
@@ -252,24 +252,24 @@ export const CarouselsPanel = () => {
<button
type="button"
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" />
<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">
{gallery.slug} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
{carousel.slug} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span>
</span>
</button>
)}
{renaming?.id !== gallery.id && (
{renaming?.id !== carousel.id && (
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setRenaming({ id: gallery.id, name: gallery.name })}
onClick={() => setRenaming({ id: carousel.id, name: carousel.name })}
disabled={busy !== null}
title="Rename"
>
@@ -282,8 +282,8 @@ export const CarouselsPanel = () => {
size="sm"
className="shrink-0"
onClick={() =>
void send(`gallery:${gallery.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${gallery.id}`),
void send(`carousel:${carousel.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${carousel.id}`),
)
}
disabled={busy !== null}
+19 -19
View File
@@ -10,7 +10,7 @@ import { MediaPicker } from '../MediaPicker';
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 ──
*
@@ -21,18 +21,18 @@ import { useAutosave } from '../useAutosave';
*
* ── 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
* hero changes, and a client that kept its own copy would go on showing a framing the server had
* already discarded.
*
* ── 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.
*/
type GalleryItem = {
type CarouselItem = {
id: number;
kind: string;
url: string;
@@ -45,13 +45,13 @@ type GalleryItem = {
type Home = {
hero: { url: string | null; crop: string | null; fit: string | null };
intro_text: string | null;
/** The OLD per-page list. Still returned by the API, and only rendered while no gallery is chosen. */
gallery: GalleryItem[];
/** Which gallery the carousel shows. Null means the old list above still applies. */
/** The OLD per-page list. Still returned by the API, and only rendered while no carousel is chosen. */
carousel: CarouselItem[];
/** Which carousel the carousel shows. Null means the old list above still applies. */
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 = () => {
const client = useClient();
@@ -65,7 +65,7 @@ export const HomePanel = () => {
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
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. */
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.
useEffect(() => {
void clientRef.current
.get<{ data: Gallery[] }>('/fofos-admin/carousels')
.get<{ data: Carousel[] }>('/fofos-admin/carousels')
.then((response) => setCarousels(response.data))
.catch(() => setCarousels([]));
}, []);
@@ -119,7 +119,7 @@ export const HomePanel = () => {
/*
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
used. The two fields below are typed, and typing is what Save is for.
*/
@@ -211,7 +211,7 @@ export const HomePanel = () => {
</section>
<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.
@@ -233,19 +233,19 @@ export const HomePanel = () => {
className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs"
>
<option value="">None</option>
{carousels.map((gallery) => (
<option key={gallery.id} value={gallery.id}>
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
{carousels.map((carousel) => (
<option key={carousel.id} value={carousel.id}>
{carousel.name} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</option>
))}
</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. */}
{home.carousel_id === null && home.gallery.length > 0 && (
{home.carousel_id === null && home.carousel.length > 0 && (
<p className="text-xs text-muted-foreground/70">
Showing {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old
per-page list. Choosing a gallery replaces it.
Showing {home.carousel.length} item{home.carousel.length === 1 ? '' : 's'} from the old
per-page list. Choosing a carousel replaces it.
</p>
)}
</section>
+9 -9
View File
@@ -162,7 +162,7 @@ export const PageBuilderPanel = () => {
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. */
/*
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
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
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.
*/
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.
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) => {
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
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;
}
if (component.kind === 'gallery') {
if (component.kind === 'carousel') {
const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId;
setChoosingCarousel({
id: component.id,
@@ -793,7 +793,7 @@ export const PageBuilderPanel = () => {
*/}
{block.kind === 'partners' && <PartnersEditor onChanged={() => setRendered((n) => n + 1)} />}
{block.kind === 'gallery' && (
{block.kind === 'carousel' && (
<div className="flex flex-col gap-3">
<Button
size="sm"
@@ -801,13 +801,13 @@ export const PageBuilderPanel = () => {
className="h-8 self-start text-xs"
onClick={() => handlersRef.current.editComponent(block)}
>
Choose gallery…
Choose carousel…
</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
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 ──
*
* 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.
*/
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
* 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
* 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 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
* Carousels.
*/
@@ -85,7 +85,7 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
);
}, [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.
useEffect(() => {
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">
<Button size="sm" variant="outline" className="h-8 gap-1.5 text-xs" onClick={() => setPicking(true)}>
<Images className="size-3.5" />
{selected.carousel_id ? 'Change gallery' : 'Choose a gallery'}
{selected.carousel_id ? 'Change carousel' : 'Choose a carousel'}
</Button>
<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. */}
<Button
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"
disabled={busy}
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" />
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>
) : !selected.carousel_id ? (
<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>
) : photos === null ? (
<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>
) : photos.length === 0 ? (
<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 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.
</p>
<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 ──
*
* 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
* hundred lines duplicated for an icon and a noun, and the copies would drift the first time one of
* 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. */
export type SubjectRecord = {
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;
title: string;
slug: string | null;