a band shows its blocks, from one shared list
Selecting a band showed its name and title and nothing about what was in it — so adding a block to the thing you were looking at meant going back out to the page. BandBlocks is that list, used by the band's editor and nested under every band in the page's structure. Same list, same actions, one component; the galleries panel taught that lesson once already. Blocks reorder within a column now. Across columns is the same call — the write carries the column — but a list per column has no gesture for crossing between them, and inventing one here would be guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { href } from '../routes';
|
||||
import { CHOICES } from './AddComponentDialog';
|
||||
import type { CanvasComponent, CanvasSection } from '../canvas';
|
||||
|
||||
/**
|
||||
* What is in one band, column by column.
|
||||
*
|
||||
* ── One list, two places ──
|
||||
*
|
||||
* 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 —
|
||||
* the galleries panel taught that lesson once already.
|
||||
*
|
||||
* ── Columns, because adding needs one ──
|
||||
*
|
||||
* A block goes IN a column, so a flat list cannot express adding one. A one-column band shows a single
|
||||
* button and never has to ask a question it has no answer to; a two-column band shows Add left and Add
|
||||
* right.
|
||||
*
|
||||
* ── Reorder is within a column ──
|
||||
*
|
||||
* The write sends the column alongside the ids, and the query sets both — so dragging a block from one
|
||||
* column to the other is the same call, on the day the interface offers it. It does not yet: a list
|
||||
* per column has no gesture for crossing between them, and inventing one here would be guessing.
|
||||
*/
|
||||
export const BandBlocks = ({
|
||||
slug,
|
||||
section,
|
||||
busy,
|
||||
onAdd,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: {
|
||||
slug: string;
|
||||
section: CanvasSection;
|
||||
busy: boolean;
|
||||
onAdd: (section: CanvasSection, column: number) => void;
|
||||
onDelete: (block: CanvasComponent) => void;
|
||||
onReorder: (section: CanvasSection, column: number, ids: number[]) => void;
|
||||
}) => {
|
||||
const [dragged, setDragged] = useState<number | null>(null);
|
||||
const [order, setOrder] = useState<CanvasComponent[] | null>(null);
|
||||
const pending = useRef<CanvasComponent[] | null>(null);
|
||||
const columns = section.layout === 'two' ? 2 : 1;
|
||||
|
||||
const blocksIn = (column: number) =>
|
||||
(order ?? section.components).filter((block) => block.column === column);
|
||||
|
||||
const moveOver = (event: React.DragEvent, over: CanvasComponent) => {
|
||||
if (dragged === null) return;
|
||||
const all = order ?? section.components;
|
||||
const from = all.findIndex((one) => one.id === dragged);
|
||||
// Within a column only — see the note above. A drop on the other column is simply not accepted.
|
||||
if (from < 0 || all[from]!.column !== over.column) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (dragged === over.id) return;
|
||||
|
||||
const next = [...all];
|
||||
const to = next.findIndex((one) => one.id === over.id);
|
||||
next.splice(to, 0, next.splice(from, 1)[0]!);
|
||||
pending.current = next;
|
||||
setOrder(next);
|
||||
};
|
||||
|
||||
const commit = (column: number) => {
|
||||
const next = pending.current;
|
||||
setDragged(null);
|
||||
pending.current = null;
|
||||
// Dropped where it started, or outside the list. Nothing moved, so nothing is written.
|
||||
if (!next) return;
|
||||
setOrder(null);
|
||||
onReorder(section, column, next.filter((one) => one.column === column).map((one) => one.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Array.from({ length: columns }, (_, column) => (
|
||||
<div key={column} className="flex flex-col gap-0.5">
|
||||
{blocksIn(column).map((block) => (
|
||||
<span
|
||||
key={block.id}
|
||||
onDragOver={(event) => moveOver(event, block)}
|
||||
onDrop={() => commit(column)}
|
||||
className={`flex items-center gap-1 ${dragged === block.id ? 'opacity-40' : ''}`}
|
||||
>
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setDragged(block.id);
|
||||
// Firefox starts no drag at all without data on the transfer. The value is unused.
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(block.id));
|
||||
}}
|
||||
onDragEnd={() => commit(column)}
|
||||
className="shrink-0 cursor-grab pl-1 text-muted-foreground/50 active:cursor-grabbing"
|
||||
aria-label="Reorder"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</span>
|
||||
|
||||
<Link
|
||||
to={href('pages', slug, String(section.id), String(block.id))}
|
||||
replace
|
||||
className="min-w-0 flex-1 truncate py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind}
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-1 text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(block)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 justify-start px-1 text-[11px] text-muted-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => onAdd(section, column)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{columns === 2 ? (column === 0 ? 'Add left' : 'Add right') : 'Add block'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,11 +23,14 @@ export const BandSettings = ({
|
||||
pageId,
|
||||
band,
|
||||
onSaved,
|
||||
blocks,
|
||||
}: {
|
||||
pageId: number;
|
||||
band: { id: number; name: string; title?: string | null };
|
||||
/** The builder re-reads its sections: the nav's row is this name, and the page draws this title. */
|
||||
onSaved: () => void;
|
||||
/** This band's blocks — `BandBlocks`, built by the panel that has the handlers. */
|
||||
blocks?: React.ReactNode;
|
||||
}) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
@@ -92,6 +95,14 @@ export const BandSettings = ({
|
||||
|
||||
{saved && <p className="text-xs text-success">Saved</p>}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{/*
|
||||
What is in this band, the same list the page's structure nests under every band.
|
||||
|
||||
A band selected and its contents not shown would mean going back out to the page to add a block
|
||||
to the thing you are already looking at.
|
||||
*/}
|
||||
{blocks}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ import { GalleryItems } from './GalleryItems';
|
||||
import { PartnersEditor } from './PartnersEditor';
|
||||
import { BlockMedia } from './BlockMedia';
|
||||
import { PageStructure } from './PageStructure';
|
||||
import { BandBlocks } from './BandBlocks';
|
||||
|
||||
/**
|
||||
* The shapes a photograph block may take.
|
||||
@@ -524,6 +525,24 @@ export const PageBuilderPanel = () => {
|
||||
const reorderRef = useRef<(ids: number[]) => Promise<void>>(async () => {});
|
||||
reorderRef.current = reorderSections;
|
||||
|
||||
/** The order of one column's blocks. The column travels with the ids — see the site's query. */
|
||||
const reorderBlocks = async (section: CanvasSection, column: number, ids: number[]) => {
|
||||
if (!page) return;
|
||||
setError(null);
|
||||
try {
|
||||
await clientRef.current.put(
|
||||
`/fofos-admin/pages/${page.id}/sections/${section.id}/components/order`,
|
||||
{ column, ids },
|
||||
);
|
||||
setRendered((n) => n + 1);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
}
|
||||
};
|
||||
|
||||
const reorderBlocksRef = useRef<typeof reorderBlocks>(async () => {});
|
||||
reorderBlocksRef.current = reorderBlocks;
|
||||
|
||||
const setContent = async (component: CanvasComponent, content: Record<string, unknown>) => {
|
||||
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
||||
if (!page || !owner) return;
|
||||
@@ -899,7 +918,21 @@ export const PageBuilderPanel = () => {
|
||||
const band = !piece && part ? sections.find((section) => String(section.id) === part) : null;
|
||||
if (band && page) {
|
||||
setEditorRef.current(
|
||||
<BandSettings pageId={page.id} band={band} onSaved={() => setRendered((n) => n + 1)} />,
|
||||
<BandSettings
|
||||
pageId={page.id}
|
||||
band={band}
|
||||
onSaved={() => setRendered((n) => n + 1)}
|
||||
blocks={
|
||||
<BandBlocks
|
||||
slug={slug ?? ''}
|
||||
section={band}
|
||||
busy={creating}
|
||||
onAdd={(one, column) => handlersRef.current.addComponent(one, column)}
|
||||
onDelete={(one) => handlersRef.current.removeComponent(one)}
|
||||
onReorder={(one, column, ids) => void reorderBlocksRef.current(one, column, ids)}
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -922,6 +955,7 @@ export const PageBuilderPanel = () => {
|
||||
onReorder={(ids) => void reorderRef.current(ids)}
|
||||
onAddComponent={(section, column) => handlersRef.current.addComponent(section, column)}
|
||||
onDeleteComponent={(block) => handlersRef.current.removeComponent(block)}
|
||||
onReorderComponents={(section, column, ids) => void reorderBlocksRef.current(section, column, ids)}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router';
|
||||
import { href } from '../routes';
|
||||
import type { CanvasComponent, CanvasSection } from '../canvas';
|
||||
import { CHOICES } from './AddComponentDialog';
|
||||
import { BandBlocks } from './BandBlocks';
|
||||
|
||||
/**
|
||||
* The page, as a list of its bands.
|
||||
@@ -34,6 +34,7 @@ export const PageStructure = ({
|
||||
onReorder,
|
||||
onAddComponent,
|
||||
onDeleteComponent,
|
||||
onReorderComponents,
|
||||
}: {
|
||||
/** For the links: pressing a band still takes you to it. */
|
||||
slug: string;
|
||||
@@ -45,6 +46,7 @@ export const PageStructure = ({
|
||||
/** A block needs a COLUMN to go in, which is the whole reason blocks are nested here. */
|
||||
onAddComponent: (section: CanvasSection, column: number) => void;
|
||||
onDeleteComponent: (component: CanvasComponent) => void;
|
||||
onReorderComponents: (section: CanvasSection, column: number, ids: number[]) => void;
|
||||
}) => {
|
||||
const [dragged, setDragged] = useState<number | null>(null);
|
||||
const [order, setOrder] = useState<CanvasSection[] | null>(null);
|
||||
@@ -128,53 +130,15 @@ export const PageStructure = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The band's columns, and what is in each.
|
||||
|
||||
Nested rather than listed flat, because ADDING a block means choosing which column it
|
||||
goes in — and a one-column band should not have to answer a question it has no answer
|
||||
to. So a band with one column shows one button and a band with two shows two.
|
||||
|
||||
The blocks themselves are links, like everywhere else: pressing one opens its editor.
|
||||
*/}
|
||||
<div className="flex flex-col gap-1 border-t border-border/60 px-2 py-1.5">
|
||||
{Array.from({ length: section.layout === 'two' ? 2 : 1 }, (_, column) => (
|
||||
<div key={column} className="flex flex-col gap-0.5">
|
||||
{section.components
|
||||
.filter((block) => block.column === column)
|
||||
.map((block) => (
|
||||
<span key={block.id} className="flex items-center gap-1">
|
||||
<Link
|
||||
to={href('pages', slug, String(section.id), String(block.id))}
|
||||
replace
|
||||
className="min-w-0 flex-1 truncate py-0.5 pl-3 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind}
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-1 text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onDeleteComponent(block)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 justify-start px-1 text-[11px] text-muted-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => onAddComponent(section, column)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{section.layout === 'two' ? (column === 0 ? 'Add left' : 'Add right') : 'Add block'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-border/60 px-2 py-1.5">
|
||||
<BandBlocks
|
||||
slug={slug}
|
||||
section={section}
|
||||
busy={busy}
|
||||
onAdd={onAddComponent}
|
||||
onDelete={onDeleteComponent}
|
||||
onReorder={onReorderComponents}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user