a gallery is a carousel
It always rendered as one — data-carousel, a snap track, two arrows, driven by carousel.client.js. The entity was the last thing still calling it something else, and that collided with the site's own Gallery section: two nav rows reading Gallery and Galleries, meaning a list of objects and a list of photograph sets. GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint becomes /fofos-admin/carousels, the block kind becomes carousel and subject-gallery becomes subject-carousel. The publish allowlist follows the renamed tables. What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's base '/gallery', and home_gallery_media. Those are the Gallery section and the legacy rows, and both are correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -41,7 +41,7 @@ const CONTENT_TABLES = [
|
|||||||
/*
|
/*
|
||||||
The page builder's own tables, and the reason they are FIRST.
|
The page builder's own tables, and the reason they are FIRST.
|
||||||
|
|
||||||
Parents before children: a section belongs to a page and a component to a section. `galleries` sits
|
Parents before children: a section belongs to a page and a component to a section. `carousels` sits
|
||||||
below them because a gallery block names a set by id — carried in JSON rather than as a foreign
|
below them because a gallery block names a set by id — carried in JSON rather than as a foreign
|
||||||
key, so nothing enforces the order, which is exactly why it is written down.
|
key, so nothing enforces the order, which is exactly why it is written down.
|
||||||
|
|
||||||
@@ -60,8 +60,8 @@ const CONTENT_TABLES = [
|
|||||||
'home_content',
|
'home_content',
|
||||||
'home_gallery_media',
|
'home_gallery_media',
|
||||||
'about_content',
|
'about_content',
|
||||||
'galleries',
|
'carousels',
|
||||||
'gallery_items',
|
'carousel_items',
|
||||||
'collections',
|
'collections',
|
||||||
'collection_items',
|
'collection_items',
|
||||||
'collection_item_media',
|
'collection_item_media',
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { readableError } from './errors';
|
import { readableError } from './errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose WHICH gallery a block shows.
|
* Choose WHICH carousel a block shows.
|
||||||
*
|
*
|
||||||
* ── A reference, not a copy ──
|
* ── A reference, not a copy ──
|
||||||
*
|
*
|
||||||
* `galleries` is a named, ordered set built to be shown in more than one place — the old home carousel
|
* `carousels` is a named, ordered set built to be shown in more than one place — the old home carousel
|
||||||
* already read one by id. So a gallery block stores that id and owns nothing else: reordering the set
|
* already read one by id. So a carousel block stores that id and owns nothing else: reordering the set
|
||||||
* reorders every block pointing at it, and a photograph swapped there is swapped everywhere. That is
|
* reorders every block pointing at it, and a photograph swapped there is swapped everywhere. That is
|
||||||
* the whole reason the set has a name instead of being a list of files on a page.
|
* the whole reason the set has a name instead of being a list of files on a page.
|
||||||
*
|
*
|
||||||
* It follows that this picker chooses a SET, and editing what is IN the set happens where the set
|
* It follows that this picker chooses a SET, and editing what is IN the set happens where the set
|
||||||
* lives — under Galleries. A block that could also rearrange its own pictures would be a second place
|
* lives — under Carousels. A block that could also rearrange its own pictures would be a second place
|
||||||
* to do the same job, and the two would disagree.
|
* to do the same job, and the two would disagree.
|
||||||
*
|
*
|
||||||
* ── No thumbnails, deliberately ──
|
* ── No thumbnails, deliberately ──
|
||||||
@@ -23,17 +23,17 @@ import { readableError } from './errors';
|
|||||||
* The plugin has no way to address a rendition: `MediaPicker` hands that job to the platform's file
|
* The plugin has no way to address a rendition: `MediaPicker` hands that job to the platform's file
|
||||||
* browser, and every path stored here names an ORIGINAL that a browser cannot display. Rather than
|
* browser, and every path stored here names an ORIGINAL that a browser cannot display. Rather than
|
||||||
* build a second, worse image pipeline for a dialog, this shows the name and the count — which is what
|
* build a second, worse image pipeline for a dialog, this shows the name and the count — which is what
|
||||||
* tells two galleries apart, since the name is the thing somebody chose.
|
* tells two carousels apart, since the name is the thing somebody chose.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GallerySummary = {
|
export type CarouselSummary = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
items: unknown[];
|
items: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GalleryPicker = ({
|
export const CarouselPicker = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onPick,
|
onPick,
|
||||||
@@ -41,7 +41,7 @@ export const GalleryPicker = ({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (galleryId: number) => void;
|
onPick: (carouselId: number) => void;
|
||||||
/** The one this block already shows, so reopening the picker says where it stands. */
|
/** The one this block already shows, so reopening the picker says where it stands. */
|
||||||
selected?: number | null;
|
selected?: number | null;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -49,19 +49,19 @@ export const GalleryPicker = ({
|
|||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const [galleries, setGalleries] = useState<GallerySummary[] | null>(null);
|
const [carousels, setCarousels] = useState<CarouselSummary[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Re-read on every open rather than once. A gallery created in the other panel while this session
|
// Re-read on every open rather than once. A carousel created in the other panel while this session
|
||||||
// has been running would otherwise be missing from a list that looks complete.
|
// has been running would otherwise be missing from a list that looks complete.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setGalleries(null);
|
setCarousels(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await clientRef.current.get<{ data: GallerySummary[] }>('/fofos-admin/galleries');
|
const response = await clientRef.current.get<{ data: CarouselSummary[] }>('/fofos-admin/carousels');
|
||||||
setGalleries(response.data);
|
setCarousels(response.data);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(readableError(failure));
|
setError(readableError(failure));
|
||||||
}
|
}
|
||||||
@@ -72,12 +72,12 @@ export const GalleryPicker = ({
|
|||||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Choose a gallery</DialogTitle>
|
<DialogTitle>Choose a carousel</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
{!galleries && !error && (
|
{!carousels && !error && (
|
||||||
<p className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
<p className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
Loading…
|
Loading…
|
||||||
@@ -85,29 +85,29 @@ export const GalleryPicker = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nothing to choose is a real state and says what to do about it, rather than an empty box.
|
{/* Nothing to choose is a real state and says what to do about it, rather than an empty box.
|
||||||
Creating one belongs under Galleries — see the note above about one place per job. */}
|
Creating one belongs under Carousels — see the note above about one place per job. */}
|
||||||
{galleries?.length === 0 && (
|
{carousels?.length === 0 && (
|
||||||
<p className="p-4 text-xs text-muted-foreground">
|
<p className="p-4 text-xs text-muted-foreground">
|
||||||
No galleries yet. Make one under Galleries, then come back.
|
No carousels yet. Make one under Carousels, then come back.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{galleries?.map((gallery) => {
|
{carousels?.map((carousel) => {
|
||||||
const count = Array.isArray(gallery.items) ? gallery.items.length : 0;
|
const count = Array.isArray(carousel.items) ? carousel.items.length : 0;
|
||||||
const current = gallery.id === selected;
|
const current = carousel.id === selected;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={gallery.id}
|
key={carousel.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPick(gallery.id)}
|
onClick={() => onPick(carousel.id)}
|
||||||
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||||
current ? 'border-primary bg-primary/10' : 'border-border hover:border-primary hover:bg-muted/50'
|
current ? 'border-primary bg-primary/10' : 'border-border hover:border-primary hover:bg-muted/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Images className="size-4 shrink-0 text-muted-foreground" />
|
<Images className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm">{gallery.name}</span>
|
<span className="block truncate text-sm">{carousel.name}</span>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{count === 1 ? '1 item' : `${count} items`}
|
{count === 1 ? '1 item' : `${count} items`}
|
||||||
</span>
|
</span>
|
||||||
+2
-2
@@ -7,7 +7,7 @@ import { AdministrationPanel } from './AdministrationPanel';
|
|||||||
import { MediaPanel } from './MediaPanel';
|
import { MediaPanel } from './MediaPanel';
|
||||||
import { CollectionsPanel } from './sections/CollectionsPanel';
|
import { CollectionsPanel } from './sections/CollectionsPanel';
|
||||||
import { PiecesPanel } from './sections/PiecesPanel';
|
import { PiecesPanel } from './sections/PiecesPanel';
|
||||||
import { GalleriesPanel } from './sections/GalleriesPanel';
|
import { CarouselsPanel } from './sections/CarouselsPanel';
|
||||||
import { LayoutPanel } from './sections/LayoutPanel';
|
import { LayoutPanel } from './sections/LayoutPanel';
|
||||||
import { PageBuilderPanel } from './sections/PageBuilderPanel';
|
import { PageBuilderPanel } from './sections/PageBuilderPanel';
|
||||||
import { ViewModeProvider } from './ViewMode';
|
import { ViewModeProvider } from './ViewMode';
|
||||||
@@ -50,7 +50,7 @@ const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
|||||||
layout: LayoutPanel,
|
layout: LayoutPanel,
|
||||||
collections: CollectionsPanel,
|
collections: CollectionsPanel,
|
||||||
pieces: PiecesPanel,
|
pieces: PiecesPanel,
|
||||||
galleries: GalleriesPanel,
|
carousels: CarouselsPanel,
|
||||||
messages: MessagesPanel,
|
messages: MessagesPanel,
|
||||||
administration: AdministrationPanel,
|
administration: AdministrationPanel,
|
||||||
media: MediaPanel,
|
media: MediaPanel,
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ export const EditorPanel = () => {
|
|||||||
{/*
|
{/*
|
||||||
Only where there is a page to look at.
|
Only where there is a page to look at.
|
||||||
|
|
||||||
The toggle sets the width the PREVIEW renders at, and Galleries, Media and Administration have
|
The toggle sets the width the PREVIEW renders at, and Carousels, Media and Administration have
|
||||||
no preview — the control would be three buttons that do nothing visible. Named here rather
|
no preview — the control would be three buttons that do nothing visible. Named here rather
|
||||||
than read from `usePreviewable`, which is a context inside the detail panel and this is its
|
than read from `usePreviewable`, which is a context inside the detail panel and this is its
|
||||||
sibling.
|
sibling.
|
||||||
|
|||||||
+2
-2
@@ -60,9 +60,9 @@ 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 `galleryId`.
|
`html` and `gallery` holds `carouselId`.
|
||||||
*/
|
*/
|
||||||
content?: { html?: string; galleryId?: 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 }>;
|
||||||
};
|
};
|
||||||
export type CanvasSection = {
|
export type CanvasSection = {
|
||||||
|
|||||||
+5
-5
@@ -29,8 +29,8 @@ const relativeTo = (path: string): string | null => {
|
|||||||
|
|
||||||
export const fileActions: FileAction[] = [
|
export const fileActions: FileAction[] = [
|
||||||
{
|
{
|
||||||
key: 'fofos-admin:create-gallery',
|
key: 'fofos-admin:create-carousel',
|
||||||
label: 'Create Gallery',
|
label: 'Carousel from folder',
|
||||||
icon: Images,
|
icon: Images,
|
||||||
/*
|
/*
|
||||||
A folder, or any number of files — both are ways of saying "these, in this order".
|
A folder, or any number of files — both are ways of saying "these, in this order".
|
||||||
@@ -76,10 +76,10 @@ export const fileActions: FileAction[] = [
|
|||||||
paths = body.files;
|
paths = body.files;
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
Handed over in the URL, and the Galleries 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 gallery, seeing the slug it will get
|
||||||
and watching the items land belongs on the screen that owns galleries, 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.
|
||||||
*/
|
*/
|
||||||
// The section is a PATH now, so this names it rather than editing the current query. `create` and
|
// The section is a PATH now, so this names it rather than editing the current query. `create` and
|
||||||
@@ -87,7 +87,7 @@ export const fileActions: FileAction[] = [
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : '');
|
params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : '');
|
||||||
params.set('from', paths.join('\n'));
|
params.set('from', paths.join('\n'));
|
||||||
window.history.pushState({}, '', `${href('galleries')}?${params.toString()}`);
|
window.history.pushState({}, '', `${href('carousels')}?${params.toString()}`);
|
||||||
// The router listens for this; pushState alone does not notify it.
|
// The router listens for this; pushState alone does not notify it.
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ import { DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
|
|||||||
* ── Why the pathname and not `useParams` ──
|
* ── Why the pathname and not `useParams` ──
|
||||||
*
|
*
|
||||||
* Which parameters exist depends on which route matched: `/fofos-admin/layout/header` has a `band` and
|
* Which parameters exist depends on which route matched: `/fofos-admin/layout/header` has a `band` and
|
||||||
* `/fofos-admin/galleries` has nothing. A panel would then have to know its own route to read its own
|
* `/fofos-admin/carousels` has nothing. A panel would then have to know its own route to read its own
|
||||||
* position, and every panel is rendered on all of them.
|
* position, and every panel is rendered on all of them.
|
||||||
*
|
*
|
||||||
* Two segments after the mount, always in the same places. One reader, every route.
|
* Two segments after the mount, always in the same places. One reader, every route.
|
||||||
@@ -58,7 +58,7 @@ export const useNav = (): {
|
|||||||
* A link to a section, or to something inside one.
|
* A link to a section, or to something inside one.
|
||||||
*
|
*
|
||||||
* The query string is dropped, and that is the point rather than an omission. `?device=tablet` is how
|
* The query string is dropped, and that is the point rather than an omission. `?device=tablet` is how
|
||||||
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
|
* you are looking at the Layout preview; carrying it into Carousels would be state from one screen
|
||||||
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
|
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
|
||||||
*/
|
*/
|
||||||
export const href = (
|
export const href = (
|
||||||
|
|||||||
+12
-12
@@ -1,5 +1,5 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react';
|
import { Boxes, Cog, Files, FolderOpen, GalleryHorizontal, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sections of the fofos admin, in nav order.
|
* The sections of the fofos admin, in nav order.
|
||||||
@@ -40,7 +40,7 @@ export type SectionKey =
|
|||||||
| 'administration'
|
| 'administration'
|
||||||
| 'collections'
|
| 'collections'
|
||||||
| 'pieces'
|
| 'pieces'
|
||||||
| 'galleries'
|
| 'carousels'
|
||||||
| 'media'
|
| 'media'
|
||||||
| 'messages'
|
| 'messages'
|
||||||
| 'layout'
|
| 'layout'
|
||||||
@@ -120,7 +120,7 @@ export const SECTIONS: Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
Galleries — 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 gallery is a set of them somebody
|
||||||
@@ -140,7 +140,7 @@ export const SECTIONS: Section[] = [
|
|||||||
path: '/fofos-admin/collections',
|
path: '/fofos-admin/collections',
|
||||||
built: true,
|
built: true,
|
||||||
/*
|
/*
|
||||||
Same reason as Galleries: a collection is a ROW read through the site's admin API, so this is
|
Same reason as Carousels: a collection is a ROW read through the site's admin API, so this is
|
||||||
useless without the development server answering. What is different is that the row is only half
|
useless without the development server answering. What is different is that the row is only half
|
||||||
of it — which collections EXIST is a question about `originals/COLLECTIONS/`, and the folders are
|
of it — which collections EXIST is a question about `originals/COLLECTIONS/`, and the folders are
|
||||||
registered as this list is read.
|
registered as this list is read.
|
||||||
@@ -153,9 +153,9 @@ export const SECTIONS: Section[] = [
|
|||||||
The Gallery's pieces.
|
The Gallery's pieces.
|
||||||
|
|
||||||
Called Pieces, not Gallery. The section on the site is Gallery and its items are peças, and there
|
Called Pieces, not Gallery. The section on the site is Gallery and its items are peças, and there
|
||||||
is already a `galleries` section here meaning something else entirely — a named set of media. Two
|
is a Carousels section here meaning something else entirely — a named, ordered set of media. The
|
||||||
nav rows called Gallery and Galleries, meaning a list of objects and a list of photograph sets,
|
two were called Gallery and Galleries once, meaning a list of objects and a list of photograph
|
||||||
is a question somebody would have to answer every time they looked at it.
|
sets, and that is what the rename to Carousels was for.
|
||||||
|
|
||||||
Below Collections because it is the same object one level flatter, and it shares its panel — see
|
Below Collections because it is the same object one level flatter, and it shares its panel — see
|
||||||
sections/subjects.ts.
|
sections/subjects.ts.
|
||||||
@@ -172,12 +172,12 @@ export const SECTIONS: Section[] = [
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'galleries',
|
key: 'carousels',
|
||||||
label: 'Galleries',
|
label: 'Carousels',
|
||||||
icon: Images,
|
icon: GalleryHorizontal,
|
||||||
hint: 'Named sets of media, reusable across the site',
|
hint: 'Named sets of media, reusable across the site',
|
||||||
// `/fofos-admin/galleries` — it reads the site's own admin API, unlike Media beside it.
|
// `/fofos-admin/carousels` — it reads the site's own admin API, unlike Media beside it.
|
||||||
path: '/fofos-admin/galleries',
|
path: '/fofos-admin/carousels',
|
||||||
built: true,
|
built: true,
|
||||||
requires: 'connection',
|
requires: 'connection',
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
export type ComponentKind =
|
export type ComponentKind =
|
||||||
| 'free-text'
|
| 'free-text'
|
||||||
| 'media'
|
| 'media'
|
||||||
| 'gallery'
|
| 'carousel'
|
||||||
| 'contact-form'
|
| 'contact-form'
|
||||||
| 'partners'
|
| 'partners'
|
||||||
| 'collections'
|
| 'collections'
|
||||||
@@ -35,7 +35,7 @@ export type ComponentKind =
|
|||||||
| 'subject-title'
|
| 'subject-title'
|
||||||
| 'subject-description'
|
| 'subject-description'
|
||||||
| 'subject-specs'
|
| 'subject-specs'
|
||||||
| 'subject-gallery';
|
| 'subject-carousel';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The blocks that draw whatever the page is ABOUT.
|
The blocks that draw whatever the page is ABOUT.
|
||||||
@@ -48,7 +48,7 @@ export const SUBJECT_KINDS: ComponentKind[] = [
|
|||||||
'subject-title',
|
'subject-title',
|
||||||
'subject-description',
|
'subject-description',
|
||||||
'subject-specs',
|
'subject-specs',
|
||||||
'subject-gallery',
|
'subject-carousel',
|
||||||
];
|
];
|
||||||
|
|
||||||
type Choice = {
|
type Choice = {
|
||||||
@@ -62,7 +62,7 @@ type Choice = {
|
|||||||
* `kind: null` means not built.
|
* `kind: null` means not built.
|
||||||
*
|
*
|
||||||
* 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
|
||||||
* galleries 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. Gallery stopped being one of them on 2026-08-31: the set, its ordering and the
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -77,7 +77,7 @@ export const CHOICES: Choice[] = [
|
|||||||
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 },
|
||||||
{ kind: 'gallery', label: 'Gallery', hint: 'A named set of media', Icon: Images },
|
{ kind: 'carousel', label: 'Carousel', hint: 'A named set of media', Icon: Images },
|
||||||
/*
|
/*
|
||||||
Three that place something the site already draws.
|
Three that place something the site already draws.
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export const CHOICES: Choice[] = [
|
|||||||
{ 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-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
|
{ kind: 'subject-carousel', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AddComponentDialog = ({
|
export const AddComponentDialog = ({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { CanvasComponent, CanvasSection } from '../canvas';
|
|||||||
*
|
*
|
||||||
* The page's structure nests this under every band, and the band's own editor shows just its own.
|
* The page's structure nests this under every band, and the band's own editor shows just its own.
|
||||||
* They are the same list of the same things with the same actions, so they are the same component —
|
* They are the same list of the same things with the same actions, so they are the same component —
|
||||||
* the galleries panel taught that lesson once already.
|
* the carousels panel taught that lesson once already.
|
||||||
*
|
*
|
||||||
* ── Columns, because adding needs one ──
|
* ── Columns, because adding needs one ──
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { thumbUrl } from '../thumbnail';
|
|||||||
/**
|
/**
|
||||||
* One gallery's contents: what is in it, in what order, and what each picture is called.
|
* One gallery's contents: what is in it, in what order, and what each picture is called.
|
||||||
*
|
*
|
||||||
* ── Why it is here and not a link to Galleries ──
|
* ── 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 gallery block on a page IS a gallery, 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
|
||||||
@@ -18,36 +18,36 @@ import { thumbUrl } from '../thumbnail';
|
|||||||
*
|
*
|
||||||
* ── It reads the gallery itself ──
|
* ── It reads the gallery itself ──
|
||||||
*
|
*
|
||||||
* `GET /galleries/: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.
|
||||||
*
|
*
|
||||||
* ── Duplication, stated plainly ──
|
* ── Duplication, stated plainly ──
|
||||||
*
|
*
|
||||||
* `GalleriesPanel` still draws its own copy of this list. That is a real duplicate and it should adopt
|
* `CarouselsPanel` still draws its own copy of this list. That is a real duplicate and it should adopt
|
||||||
* this component — its version carries the same operations against the same routes, plus creating and
|
* this component — its version carries the same operations against the same routes, plus creating and
|
||||||
* deleting whole galleries, which this deliberately does not.
|
* deleting whole carousels, which this deliberately does not.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 Gallery = { id: number; name: string; slug: string; items: Item[] };
|
||||||
|
|
||||||
export const GalleryItems = ({ galleryId, onChanged }: { galleryId: 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, setGallery] = useState<Gallery | null>(null);
|
const [gallery, setCarousel] = useState<Gallery | 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 {
|
||||||
setGallery((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/galleries/${galleryId}`)).data);
|
setCarousel((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/carousels/${carouselId}`)).data);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(readableError(failure));
|
setError(readableError(failure));
|
||||||
}
|
}
|
||||||
}, [galleryId]);
|
}, [carouselId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -95,7 +95,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
|
|||||||
|
|
||||||
next.splice(to, 0, next.splice(from, 1)[0]!);
|
next.splice(to, 0, next.splice(from, 1)[0]!);
|
||||||
orderedRef.current = next;
|
orderedRef.current = next;
|
||||||
setGallery({ ...gallery, items: next });
|
setCarousel({ ...gallery, items: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitOrder = () => {
|
const commitOrder = () => {
|
||||||
@@ -106,7 +106,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
|
|||||||
if (!ordered) return;
|
if (!ordered) return;
|
||||||
|
|
||||||
void send(() =>
|
void send(() =>
|
||||||
clientRef.current.put(`/fofos-admin/galleries/${galleryId}/items/order`, { ids: ordered.map((one) => one.id) }),
|
clientRef.current.put(`/fofos-admin/carousels/${carouselId}/items/order`, { ids: ordered.map((one) => one.id) }),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
|
|||||||
const value = event.target.value.trim();
|
const value = event.target.value.trim();
|
||||||
if (value === (item.alt ?? '')) return;
|
if (value === (item.alt ?? '')) return;
|
||||||
void send(() =>
|
void send(() =>
|
||||||
clientRef.current.patch(`/fofos-admin/galleries/${galleryId}/items/${item.id}`, {
|
clientRef.current.patch(`/fofos-admin/carousels/${carouselId}/items/${item.id}`, {
|
||||||
alt: value || null,
|
alt: value || null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -190,7 +190,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
|
|||||||
className="h-7 px-1.5 text-destructive"
|
className="h-7 px-1.5 text-destructive"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void send(() => clientRef.current.delete(`/fofos-admin/galleries/${galleryId}/items/${item.id}`))
|
void send(() => clientRef.current.delete(`/fofos-admin/carousels/${carouselId}/items/${item.id}`))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
@@ -211,7 +211,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
|
|||||||
onClose={() => setPicking(false)}
|
onClose={() => setPicking(false)}
|
||||||
onPick={(url) => {
|
onPick={(url) => {
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
void send(() => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] }));
|
void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] }));
|
||||||
}}
|
}}
|
||||||
title="Add to this gallery"
|
title="Add to this gallery"
|
||||||
/>
|
/>
|
||||||
@@ -6,10 +6,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useEditorSlot } from '../useEditorSlot';
|
import { useEditorSlot } from '../useEditorSlot';
|
||||||
import { GalleryItems } from './GalleryItems';
|
import { CarouselItems } from './CarouselItems';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Galleries — 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 gallery is not a list on a page ──
|
||||||
*
|
*
|
||||||
@@ -28,12 +28,12 @@ import { GalleryItems } from './GalleryItems';
|
|||||||
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 Gallery = { id: number; name: string; slug: string; items: Item[] };
|
||||||
|
|
||||||
export const GalleriesPanel = () => {
|
export const CarouselsPanel = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const [galleries, setGalleries] = useState<Gallery[] | null>(null);
|
const [carousels, setCarousels] = useState<Gallery[] | 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. */
|
||||||
@@ -46,11 +46,11 @@ export const GalleriesPanel = () => {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/galleries');
|
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/carousels');
|
||||||
setGalleries(response.data);
|
setCarousels(response.data);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(readableError(failure));
|
setError(readableError(failure));
|
||||||
setGalleries([]);
|
setCarousels([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ export const GalleriesPanel = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditorRef.current(
|
setEditorRef.current(
|
||||||
openId === null ? null : <GalleryItems galleryId={openId} onChanged={() => void load()} />,
|
openId === null ? null : <CarouselItems carouselId={openId} onChanged={() => void load()} />,
|
||||||
);
|
);
|
||||||
}, [openId, load]);
|
}, [openId, load]);
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export const GalleriesPanel = () => {
|
|||||||
|
|
||||||
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 gallery, seeing the slug it gets and watching the items land belongs on the screen that
|
||||||
owns galleries. 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.
|
gallery on every refresh, which somebody discovers as four copies.
|
||||||
*/
|
*/
|
||||||
const arriving = params.get('from');
|
const arriving = params.get('from');
|
||||||
@@ -124,8 +124,8 @@ export const GalleriesPanel = () => {
|
|||||||
/*
|
/*
|
||||||
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.gallery_id`, and a gallery
|
Everything that points at a gallery points at its id — `collections.carousel_id`, and a gallery
|
||||||
block's `content.galleryId`. 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.
|
||||||
@@ -136,7 +136,7 @@ export const GalleriesPanel = () => {
|
|||||||
const rename = () =>
|
const rename = () =>
|
||||||
send('rename', async () => {
|
send('rename', async () => {
|
||||||
if (!renaming?.name.trim()) return;
|
if (!renaming?.name.trim()) return;
|
||||||
await clientRef.current.patch(`/fofos-admin/galleries/${renaming.id}`, { name: renaming.name.trim() });
|
await clientRef.current.patch(`/fofos-admin/carousels/${renaming.id}`, { name: renaming.name.trim() });
|
||||||
setRenaming(null);
|
setRenaming(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,17 +144,17 @@ export const GalleriesPanel = () => {
|
|||||||
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 gallery behind whenever the second call failed.
|
||||||
await clientRef.current.post('/fofos-admin/galleries', { name: name.trim(), urls: pending });
|
await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending });
|
||||||
setName('');
|
setName('');
|
||||||
setPending([]);
|
setPending([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!galleries) return <div className="p-5 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
if (!carousels) return <div className="p-5 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
||||||
<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">Galleries</h3>
|
<h3 className="text-sm font-medium">Carousels</h3>
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
@@ -193,13 +193,13 @@ export const GalleriesPanel = () => {
|
|||||||
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 gallery — its pictures, their order, their captions — are announced to the
|
||||||
editor column and drawn by `GalleryItems`, which is the same component a gallery BLOCK on a
|
editor column and drawn by `CarouselItems`, which is the same component a gallery 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 galleries 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.
|
||||||
*/}
|
*/}
|
||||||
{galleries.map((gallery) => {
|
{carousels.map((gallery) => {
|
||||||
const open = openId === gallery.id;
|
const open = openId === gallery.id;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -283,7 +283,7 @@ export const GalleriesPanel = () => {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void send(`gallery:${gallery.id}`, () =>
|
void send(`gallery:${gallery.id}`, () =>
|
||||||
clientRef.current.delete(`/fofos-admin/galleries/${gallery.id}`),
|
clientRef.current.delete(`/fofos-admin/carousels/${gallery.id}`),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
+12
-12
@@ -48,7 +48,7 @@ type Home = {
|
|||||||
/** 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 gallery is chosen. */
|
||||||
gallery: GalleryItem[];
|
gallery: GalleryItem[];
|
||||||
/** Which gallery the carousel shows. Null means the old list above still applies. */
|
/** Which gallery the carousel shows. Null means the old list above still applies. */
|
||||||
gallery_id: number | null;
|
carousel_id: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Gallery = { id: number; name: string; slug: string; items: unknown[] };
|
type Gallery = { id: number; name: string; slug: string; items: unknown[] };
|
||||||
@@ -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 [galleries, setGalleries] = useState<Gallery[]>([]);
|
const [carousels, setCarousels] = useState<Gallery[]>([]);
|
||||||
|
|
||||||
/** 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) => {
|
||||||
@@ -87,13 +87,13 @@ export const HomePanel = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// The galleries to choose between, read from the section that owns them rather than embedded in the
|
// The carousels to choose between, read from the section that owns them rather than embedded in the
|
||||||
// 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/galleries')
|
.get<{ data: Gallery[] }>('/fofos-admin/carousels')
|
||||||
.then((response) => setGalleries(response.data))
|
.then((response) => setCarousels(response.data))
|
||||||
.catch(() => setGalleries([]));
|
.catch(() => setCarousels([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
||||||
@@ -217,15 +217,15 @@ export const HomePanel = () => {
|
|||||||
A picker, not a list.
|
A picker, not a list.
|
||||||
|
|
||||||
Managing items here would be a second editor for something that already has one, and the two
|
Managing items here would be a second editor for something that already has one, and the two
|
||||||
would drift: a caption fixed in one place and not the other. The page chooses; the Galleries
|
would drift: a caption fixed in one place and not the other. The page chooses; the Carousels
|
||||||
section arranges.
|
section arranges.
|
||||||
*/}
|
*/}
|
||||||
<select
|
<select
|
||||||
value={home.gallery_id ?? ''}
|
value={home.carousel_id ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
void send('gallery', () =>
|
void send('carousel', () =>
|
||||||
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
|
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
|
||||||
gallery_id: event.target.value ? Number(event.target.value) : null,
|
carousel_id: event.target.value ? Number(event.target.value) : null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ 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>
|
||||||
{galleries.map((gallery) => (
|
{carousels.map((gallery) => (
|
||||||
<option key={gallery.id} value={gallery.id}>
|
<option key={gallery.id} value={gallery.id}>
|
||||||
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
||||||
</option>
|
</option>
|
||||||
@@ -242,7 +242,7 @@ export const HomePanel = () => {
|
|||||||
|
|
||||||
{/* 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 gallery is chosen, and saying so
|
||||||
beats a carousel that appears to come from nowhere. */}
|
beats a carousel that appears to come from nowhere. */}
|
||||||
{home.gallery_id === null && home.gallery.length > 0 && (
|
{home.carousel_id === null && home.gallery.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.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old
|
||||||
per-page list. Choosing a gallery replaces it.
|
per-page list. Choosing a gallery replaces it.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection
|
|||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
'free-text': 'Text',
|
'free-text': 'Text',
|
||||||
media: 'Media',
|
media: 'Media',
|
||||||
gallery: 'Gallery',
|
carousel: 'Carousel',
|
||||||
'contact-form': 'Contact form',
|
'contact-form': 'Contact form',
|
||||||
partners: 'Partners',
|
partners: 'Partners',
|
||||||
collections: 'Collections',
|
collections: 'Collections',
|
||||||
@@ -28,7 +28,7 @@ const LABELS: Record<string, string> = {
|
|||||||
'subject-title': 'Name',
|
'subject-title': 'Name',
|
||||||
'subject-description': 'Description',
|
'subject-description': 'Description',
|
||||||
'subject-specs': 'Specs',
|
'subject-specs': 'Specs',
|
||||||
'subject-gallery': 'Photographs',
|
'subject-carousel': 'Photographs',
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -47,14 +47,14 @@ const PLACES_ONLY: string[] = [
|
|||||||
'subject-title',
|
'subject-title',
|
||||||
'subject-description',
|
'subject-description',
|
||||||
'subject-specs',
|
'subject-specs',
|
||||||
'subject-gallery',
|
'subject-carousel',
|
||||||
];
|
];
|
||||||
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||||
import { FreeTextEditor } from './FreeTextEditor';
|
import { FreeTextEditor } from './FreeTextEditor';
|
||||||
import { SpacingEditor, type Padding } from './SpacingEditor';
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
||||||
import { BandSettings } from './BandSettings';
|
import { BandSettings } from './BandSettings';
|
||||||
import { BlockSettings } from './BlockSettings';
|
import { BlockSettings } from './BlockSettings';
|
||||||
import { GalleryItems } from './GalleryItems';
|
import { CarouselItems } from './CarouselItems';
|
||||||
import { PartnersEditor } from './PartnersEditor';
|
import { PartnersEditor } from './PartnersEditor';
|
||||||
import { BlockMedia } from './BlockMedia';
|
import { BlockMedia } from './BlockMedia';
|
||||||
import { PageStructure } from './PageStructure';
|
import { PageStructure } from './PageStructure';
|
||||||
@@ -89,7 +89,7 @@ const shapeOf = (block: { content?: { url?: string; ratio?: number } }): string
|
|||||||
import { useEditorSlot } from '../useEditorSlot';
|
import { useEditorSlot } from '../useEditorSlot';
|
||||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
import { GalleryPicker } from '../GalleryPicker';
|
import { CarouselPicker } from '../CarouselPicker';
|
||||||
import { pageFromParam, pageParam, usePages, useSections, type Page } from '../usePages';
|
import { pageFromParam, pageParam, usePages, useSections, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,14 +165,14 @@ export const PageBuilderPanel = () => {
|
|||||||
Which gallery a block shows, while it is being chosen.
|
Which gallery 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. `galleryId` rides along so
|
there is nothing to draft and nothing to cancel out of afterwards. `carouselId` rides along so
|
||||||
reopening the picker can show where the block already stands.
|
reopening the picker can show where the block already stands.
|
||||||
*/
|
*/
|
||||||
const [choosingGallery, setChoosingGallery] = useState<{
|
const [choosingCarousel, setChoosingCarousel] = useState<{
|
||||||
id: number | null;
|
id: number | null;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
column: number;
|
column: number;
|
||||||
galleryId: number | null;
|
carouselId: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
||||||
@@ -248,7 +248,7 @@ export const PageBuilderPanel = () => {
|
|||||||
/*
|
/*
|
||||||
Selecting a block shows its settings. It does not open a picker.
|
Selecting a block shows its settings. It does not open a picker.
|
||||||
|
|
||||||
`editComponent` means "edit what is IN this block", and for media and galleries that is a modal
|
`editComponent` means "edit what is IN this block", and for media and carousels that is a modal
|
||||||
over the whole screen. Firing it on mere selection meant clicking a row in the nav threw a dialog
|
over the whole screen. Firing it on mere selection meant clicking a row in the nav threw a dialog
|
||||||
at you before you had said you wanted one — those kinds get a button in the settings instead.
|
at you before you had said you wanted one — those kinds get a button in the settings instead.
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ export const PageBuilderPanel = () => {
|
|||||||
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
|
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
|
||||||
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
|
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
|
||||||
if (kind === 'media') setPicking({ id: null, ...at });
|
if (kind === 'media') setPicking({ id: null, ...at });
|
||||||
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
|
if (kind === 'carousel') setChoosingCarousel({ id: null, ...at, carouselId: null });
|
||||||
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
|
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -494,23 +494,23 @@ export const PageBuilderPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
|
A gallery 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
|
||||||
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
|
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
|
||||||
*/
|
*/
|
||||||
const saveGallery = async (galleryId: number) => {
|
const saveCarousel = async (carouselId: number) => {
|
||||||
if (!page || !choosingGallery) return;
|
if (!page || !choosingCarousel) return;
|
||||||
const base = `/fofos-admin/pages/${page.id}/sections/${choosingGallery.sectionId}/components`;
|
const base = `/fofos-admin/pages/${page.id}/sections/${choosingCarousel.sectionId}/components`;
|
||||||
const target = choosingGallery;
|
const target = choosingCarousel;
|
||||||
setChoosingGallery(null);
|
setChoosingCarousel(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (target.id === null) {
|
if (target.id === null) {
|
||||||
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
|
await clientRef.current.post(base, { kind: 'carousel', column: target.column, content: { carouselId } });
|
||||||
} else {
|
} else {
|
||||||
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
|
await clientRef.current.patch(`${base}/${target.id}`, { content: { carouselId } });
|
||||||
}
|
}
|
||||||
setRendered((n) => n + 1);
|
setRendered((n) => n + 1);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
@@ -590,12 +590,12 @@ export const PageBuilderPanel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (component.kind === 'gallery') {
|
if (component.kind === 'gallery') {
|
||||||
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
|
const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId;
|
||||||
setChoosingGallery({
|
setChoosingCarousel({
|
||||||
id: component.id,
|
id: component.id,
|
||||||
sectionId: owner.id,
|
sectionId: owner.id,
|
||||||
column: component.column,
|
column: component.column,
|
||||||
galleryId: typeof held === 'number' ? held : null,
|
carouselId: typeof held === 'number' ? held : null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -805,14 +805,14 @@ export const PageBuilderPanel = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
The gallery's own contents, here rather than a link to the Galleries section.
|
The gallery'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 gallery block on a page IS a gallery, 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.
|
||||||
*/}
|
*/}
|
||||||
{typeof block.content?.galleryId === 'number' && (
|
{typeof block.content?.carouselId === 'number' && (
|
||||||
<GalleryItems galleryId={block.content.galleryId} onChanged={() => setRendered((n) => n + 1)} />
|
<CarouselItems carouselId={block.content.carouselId} onChanged={() => setRendered((n) => n + 1)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1050,11 +1050,11 @@ export const PageBuilderPanel = () => {
|
|||||||
title="Choose a photograph or a video"
|
title="Choose a photograph or a video"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GalleryPicker
|
<CarouselPicker
|
||||||
open={choosingGallery !== null}
|
open={choosingCarousel !== null}
|
||||||
onClose={() => setChoosingGallery(null)}
|
onClose={() => setChoosingCarousel(null)}
|
||||||
onPick={(galleryId) => void saveGallery(galleryId)}
|
onPick={(carouselId) => void saveCarousel(carouselId)}
|
||||||
selected={choosingGallery?.galleryId ?? null}
|
selected={choosingCarousel?.carouselId ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { thumbUrl } from '../thumbnail';
|
|||||||
* That is worth saying out loud in the panel, because the editor sits inside a block on a page and
|
* That is worth saying out loud in the panel, because the editor sits inside a block on a page and
|
||||||
* everything else in that column belongs to the page.
|
* everything else in that column belongs to the page.
|
||||||
*
|
*
|
||||||
* ── The same shape as GalleryItems ──
|
* ── The same shape as CarouselItems ──
|
||||||
*
|
*
|
||||||
* Drag to reorder, a grip on the left, one write at the end carrying every id. Deliberately, so the
|
* Drag to reorder, a grip on the left, one write at the end carrying every id. Deliberately, so the
|
||||||
* two ordered lists in this admin are the same gesture rather than two things to learn.
|
* two ordered lists in this admin are the same gesture rather than two things to learn.
|
||||||
@@ -64,7 +64,7 @@ export const PartnersEditor = ({ onChanged }: { onChanged?: () => void }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Reordering, live. Same as GalleryItems — see the note there. */
|
/* Reordering, live. Same as CarouselItems — see the note there. */
|
||||||
const [dragged, setDragged] = useState<number | null>(null);
|
const [dragged, setDragged] = useState<number | null>(null);
|
||||||
const orderedRef = useRef<Partner[] | null>(null);
|
const orderedRef = useRef<Partner[] | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type RecordRow = { id: number; title: string; sort_order: number } & Record<stri
|
|||||||
* A thumbnail URL, authenticated by `?token=`.
|
* A thumbnail URL, authenticated by `?token=`.
|
||||||
*
|
*
|
||||||
* An `<img>` cannot send an Authorization header, which is why `user-middleware` and the permission
|
* An `<img>` cannot send an Authorization header, which is why `user-middleware` and the permission
|
||||||
* gate both accept the token in the query string. The same thing GalleriesPanel and the file browser
|
* gate both accept the token in the query string. The same thing CarouselsPanel and the file browser
|
||||||
* do — built without it first, and every picture 401s while the layout looks perfectly correct.
|
* do — built without it first, and every picture 401s while the layout looks perfectly correct.
|
||||||
*/
|
*/
|
||||||
const thumbUrl = (path: string): string => {
|
const thumbUrl = (path: string): string => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Images, Loader2, Plus, Trash2 } from 'lucide-react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { GalleryPicker } from '../GalleryPicker';
|
import { CarouselPicker } from '../CarouselPicker';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useEditorSlot } from '../useEditorSlot';
|
import { useEditorSlot } from '../useEditorSlot';
|
||||||
import { thumbUrl } from '../thumbnail';
|
import { thumbUrl } from '../thumbnail';
|
||||||
@@ -27,7 +27,7 @@ import type { SubjectPhoto, SubjectRecord, SubjectSection } from './subjects';
|
|||||||
*
|
*
|
||||||
* The photographs belong to the gallery, and so does their order — including which one leads, which
|
* The photographs belong to the gallery, 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
|
||||||
* Galleries.
|
* Carousels.
|
||||||
*/
|
*/
|
||||||
export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: SubjectSection<T> }) => {
|
export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: SubjectSection<T> }) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@@ -167,11 +167,11 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const chooseGallery = (galleryId: number) =>
|
const chooseCarousel = (carouselId: number) =>
|
||||||
send(async () => {
|
send(async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { gallery_id: galleryId });
|
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { carousel_id: carouselId });
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,11 +230,11 @@ 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.gallery_id ? 'Change gallery' : 'Choose a gallery'}
|
{selected.carousel_id ? 'Change gallery' : 'Choose a gallery'}
|
||||||
</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 gallery, which
|
||||||
belongs to Galleries. 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"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -251,7 +251,7 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
|
|||||||
|
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
<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.gallery_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 gallery yet. Choose one and its photographs appear here.
|
||||||
</p>
|
</p>
|
||||||
@@ -261,13 +261,13 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
|
|||||||
</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 Galleries.
|
That gallery 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 gallery’s order — the first one leads.
|
||||||
Reorder them in Galleries.
|
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">
|
||||||
{photos.map((photo) => (
|
{photos.map((photo) => (
|
||||||
@@ -286,11 +286,11 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
|
|||||||
{/* `selected` guarded: the picker writes to whatever is open, and there is nothing to write to
|
{/* `selected` guarded: the picker writes to whatever is open, and there is nothing to write to
|
||||||
when nothing is. */}
|
when nothing is. */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<GalleryPicker
|
<CarouselPicker
|
||||||
open={picking}
|
open={picking}
|
||||||
onClose={() => setPicking(false)}
|
onClose={() => setPicking(false)}
|
||||||
onPick={(galleryId) => void chooseGallery(galleryId)}
|
onPick={(carouselId) => void chooseCarousel(carouselId)}
|
||||||
selected={selected.gallery_id}
|
selected={selected.carousel_id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Boxes, Shapes } from 'lucide-react';
|
|||||||
export type SubjectRecord = {
|
export type SubjectRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
/** The gallery holding its media, or null while nobody has chosen one. */
|
/** The gallery holding its media, or null while nobody has chosen one. */
|
||||||
gallery_id: number | null;
|
carousel_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import { getHeaders } from 'hooks/useClient';
|
|||||||
*
|
*
|
||||||
* ── Why this is a module ──
|
* ── Why this is a module ──
|
||||||
*
|
*
|
||||||
* It was copied into GalleriesPanel and again into RecordsPanel, and a third copy was about to go into
|
* It was copied into CarouselsPanel and again into RecordsPanel, and a third copy was about to go into
|
||||||
* MediaField. Three copies of a URL shape is three places to be wrong about the rung when the ladder
|
* MediaField. Three copies of a URL shape is three places to be wrong about the rung when the ladder
|
||||||
* changes — which it did, four times in a day.
|
* changes — which it did, four times in a day.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user