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:
+1
-1
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
*/}
|
*/}
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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’s order — the first one leads.
|
{photos.length} photographs, in the carousel’s order — 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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user