Files
fofos-admin/web/sections/AddComponentDialog.tsx
T
pastilhasandClaude Opus 5 9ca6077e2c Collabs, as a third configuration of one panel
The same object as a collection and a gallery piece, so it is a config in
subjects.ts and a one-line panel — no new screen.

Two differences, both deliberate. It has no SPECS: a collab is a project rather
than an object, so there is no measurement or raw material and the form is a
name, an address and a description. And its title column is `project_name`,
which is the client's word and stays — the site's API publishes both that and
`title` so this panel reads one field like every other section, and the
translation lives in one router rather than as a branch in every screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:42:26 +01:00

173 lines
7.7 KiB
TypeScript

import { Handshake, Heading, Image, Images, LayoutGrid, Mail, Ruler, Type, type LucideIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
/**
* Choosing what to put in a column.
*
* ── One option is still a choice worth showing ──
*
* I had argued the opposite: Free Text is the only kind that works, so opening the editor straight away
* saves a click that asks to be got past. That was wrong in the way a shortcut usually is — it optimised
* the state of the app today and hid the shape of it. Somebody adding a component cannot see what a
* component IS, or that there will be others, and the day the second kind lands the interaction changes
* underneath them.
*
* It is also the pattern this admin already uses. The nav lists unbuilt sections as `soon` rather than
* hiding them, on the reasoning that a nav showing only what is finished gives no sense of what the
* thing is for. The same argument applies here.
*
* ── The list is in the plugin, not the API ──
*
* The API decides what may be STORED — `free-text`, today. This decides what may be OFFERED, and it has
* to live here because every kind needs a label, an icon and an editor that only exists in this plugin.
* A kind the server would accept and this has never heard of is not creatable anyway.
*/
export type ComponentKind =
| 'free-text'
| 'media'
| 'carousel'
| 'contact-form'
| 'partners'
| 'collections'
| 'pieces'
| 'collabs'
| 'subject-cover'
| 'subject-title'
| 'subject-description'
| 'subject-specs'
| 'subject-carousel';
/*
The blocks that draw whatever the page is ABOUT.
Only offered on a template. On an ordinary page they would render nothing, which is the worst kind of
block: one that is available, adds cleanly, and silently does nothing.
*/
export const SUBJECT_KINDS: ComponentKind[] = [
'subject-cover',
'subject-title',
'subject-description',
'subject-specs',
'subject-carousel',
];
type Choice = {
kind: ComponentKind | null;
label: string;
hint: string;
Icon: LucideIcon;
};
/**
* `kind: null` means not built.
*
* Named rather than omitted, and the ones named are things the site already HAS — media, video and
* carousels all exist as concepts here. That is the difference between saying what is coming and
* inventing a roadmap. The carousel stopped being one of them on 2026-08-31: the set, its ordering and
* carousel that draws it were all already built, so the block is a reference to one.
*/
export const CHOICES: Choice[] = [
{ kind: 'free-text', label: 'Text', hint: 'Words, headings and links', Icon: Type },
/*
One entry, either kind, because the block does not store which.
Image and Video were two rows — one of them greyed out as `soon` — and the distinction was never
the editor's to make: the path names a real file and what that file IS is a fact about it. So the
picker asks for a file and the site works out the rest, which is also what a carousel item has
always done.
*/
{ kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image },
{ kind: 'carousel', label: 'Carousel', hint: 'A named set of media', Icon: Images },
/*
Three that place something the site already draws.
Nothing to choose and nothing to type, so adding one is a single click and there is no editor
behind it. They are here rather than hidden because a page is built by picking blocks, and a block
somebody cannot find is a block that does not exist.
`collections` is the first whose content is a QUERY rather than a shared list somebody maintains —
it holds nothing at all, so a collection added tomorrow appears wherever one of these sits without
anybody opening a page. The hint says "every" for that reason: it is not a selection.
*/
{ kind: 'contact-form', label: 'Contact form', hint: 'The site\'s form, as it is', Icon: Mail },
{ kind: 'partners', label: 'Partners', hint: 'The partner logos, from Site', Icon: Handshake },
{ kind: 'collections', label: 'Collections', hint: 'Every collection, as a grid', Icon: LayoutGrid },
{ kind: 'pieces', label: 'Pieces', hint: 'Every gallery piece, as a grid', Icon: LayoutGrid },
{ kind: 'collabs', label: 'Collabs', hint: 'Every collaboration, as a grid', Icon: LayoutGrid },
/*
The five that draw whatever the page is ABOUT, on a template.
They hold nothing — not even a reference. The subject is the URL, so one block draws a different
collection on every request, and that is what lets one arrangement serve all of them.
Called `subject-` rather than `collection-`: the same five draw a gallery piece, and the labels say
"it" for that reason. What differs between the sections is a path and a few spec labels, and both
travel with the subject.
*/
{ kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its carousel, large', Icon: Image },
{ kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading },
{ kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler },
{ kind: 'subject-carousel', label: 'Its photographs', hint: 'The carousel it points at', Icon: Images },
];
export const AddComponentDialog = ({
open,
onCancel,
onChoose,
template,
}: {
open: boolean;
onCancel: () => void;
onChoose: (kind: ComponentKind) => void;
/**
* Whether this page is a TEMPLATE, which decides whether the subject blocks are offered.
*
* Hidden rather than disabled elsewhere. A greyed-out row invites the question "why not here", and
* the answer — this page has no subject — is not something a tooltip fixes. On a page that has one
* they are the point; on any other they would render nothing at all.
*/
template?: boolean;
}) => (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add a component</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-2">
{/* `kind === null` is a choice that is not built yet — never a subject block, and it must not
be filtered out by a list that only knows about the ones that are. */}
{CHOICES.filter((choice) => template || choice.kind === null || !SUBJECT_KINDS.includes(choice.kind)).map(
({ kind, label, hint, Icon }) => (
<button
key={label}
type="button"
disabled={kind === null}
onClick={() => kind && onChoose(kind)}
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors ${
kind === null
? 'cursor-not-allowed border-border opacity-45'
: 'border-border hover:border-primary hover:bg-muted/50'
}`}
>
<span className="flex items-baseline gap-2">
<Icon className="size-4 shrink-0 self-center text-muted-foreground" />
<span className="text-sm">{label}</span>
{/* Dimmed and labelled rather than hidden, the same way the nav marks a section that is
not built. A list of only what is finished gives no sense of what this is for. */}
{kind === null && (
<span className="text-[10px] tracking-wide text-muted-foreground/70 uppercase">soon</span>
)}
</span>
<span className="text-[11px] text-muted-foreground">{hint}</span>
</button>
),
)}
</div>
</DialogContent>
</Dialog>
);