The site serves nothing but derived files, so a block pointing at an original the pipeline has not been through renders an empty box — on the page, in the preview and in the editor. That is a normal thing to hit: drop a file in, choose it in the same minute, and you are ahead of a conversion that only starts twenty seconds after the folder goes quiet. The action bar already said "Sync 3". What was missing was the link from THIS blank block to that number, which is the half that costs an afternoon: an empty box looks like a block saved wrong, so it gets looked for in the wrong place. So the failed thumbnail is caught and named, using the state the bar already polls: converting now, in a run that started before this file, outstanding for the next one, or nothing running — with Sync now in place for the last case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1040 lines
42 KiB
TypeScript
1040 lines
42 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Button } from '@/components/ui/button';
|
|
import { posterUrl, thumbUrl } from '../thumbnail';
|
|
import { href, useNav } from '../routes';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
|
import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection } from '../canvas';
|
|
|
|
/*
|
|
What the Spacing dialog calls a block, by kind.
|
|
|
|
A map rather than a chain of ternaries: it was two kinds and readable, then three and not. Unknown
|
|
falls back to 'Text' — a label is a heading on a dialog, and the worst a wrong one does is read oddly
|
|
for a kind nobody has finished adding.
|
|
*/
|
|
const LABELS: Record<string, string> = {
|
|
'free-text': 'Text',
|
|
media: 'Media',
|
|
carousel: 'Carousel',
|
|
'contact-form': 'Contact form',
|
|
partners: 'Partners',
|
|
collections: 'Collections',
|
|
pieces: 'Pieces',
|
|
'subject-cover': 'Lead photograph',
|
|
'subject-title': 'Name',
|
|
'subject-description': 'Description',
|
|
'subject-specs': 'Specs',
|
|
'subject-carousel': 'Photographs',
|
|
};
|
|
|
|
/*
|
|
The kinds that hold nothing, named once.
|
|
|
|
Both the picker and the canvas ask the same question — one to decide whether to open an editor, the
|
|
other whether to draw an Edit button — and two lists would drift into a button that opens nothing.
|
|
*/
|
|
const PLACES_ONLY: string[] = [
|
|
'contact-form',
|
|
'partners',
|
|
'collections',
|
|
'pieces',
|
|
// The subject blocks hold nothing either: what they draw is the row the URL names.
|
|
'subject-cover',
|
|
'subject-title',
|
|
'subject-description',
|
|
'subject-specs',
|
|
'subject-carousel',
|
|
];
|
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
|
import { FreeTextEditor } from './FreeTextEditor';
|
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
|
import { BandSettings } from './BandSettings';
|
|
import { BlockSettings } from './BlockSettings';
|
|
import { CarouselItems } from './CarouselItems';
|
|
import { PartnersEditor } from './PartnersEditor';
|
|
import { MediaBlockSummary } from './MediaBlockSummary';
|
|
import { BlockMedia } from './BlockMedia';
|
|
import { PageStructure } from './PageStructure';
|
|
import { BandBlocks } from './BandBlocks';
|
|
|
|
/**
|
|
* The shapes a photograph block may take.
|
|
*
|
|
* One list, because three things need it and must agree: the select's options, the number stored as
|
|
* `content.ratio`, and the Tailwind aspect `MediaField` frames into. Two of those are strings and one
|
|
* is a float, which is exactly the drift a single table prevents.
|
|
*/
|
|
const SHAPES = [
|
|
{ value: '', label: "The picture's own shape", ratio: null as number | null },
|
|
{ value: '21/9', label: '21:9 — a letterbox band', ratio: 21 / 9 },
|
|
{ value: '16/9', label: '16:9', ratio: 16 / 9 },
|
|
{ value: '3/2', label: '3:2', ratio: 3 / 2 },
|
|
{ value: '4/3', label: '4:3', ratio: 4 / 3 },
|
|
{ value: '1/1', label: '1:1 — square', ratio: 1 },
|
|
{ value: '3/4', label: '3:4 — upright', ratio: 3 / 4 },
|
|
{ value: '2/3', label: '2:3 — upright', ratio: 2 / 3 },
|
|
];
|
|
|
|
/** Stored as a float, matched with a tolerance: 21/9 does not survive a round trip exactly. */
|
|
const closeTo = (a: number, b: unknown): boolean => typeof b === 'number' && Math.abs(a - b) < 0.01;
|
|
|
|
/** The Tailwind aspect for a block's stored ratio, or null when it keeps its own shape. */
|
|
const shapeOf = (block: { content?: { url?: string; ratio?: number } }): string | null => {
|
|
if (!block.content?.url || /\.(mp4|mov|webm|m4v)$/i.test(block.content.url)) return null;
|
|
return SHAPES.find((one) => one.ratio && closeTo(one.ratio, block.content?.ratio))?.value ?? null;
|
|
};
|
|
import { useEditorSlot } from '../useEditorSlot';
|
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
|
import { MediaPicker } from '../MediaPicker';
|
|
import { CarouselPicker } from '../CarouselPicker';
|
|
import { pageFromParam, pageParam, usePages, useSections, type Page } from '../usePages';
|
|
|
|
/**
|
|
* One page, being built.
|
|
*
|
|
* ── Why this is a different panel from Pages ──
|
|
*
|
|
* Pages is the LIST: what exists, what it is called, where it lives, in what order. This is one PAGE:
|
|
* what is on it. They are different jobs and the nav already says so — Pages is a section and the pages
|
|
* are rows underneath it — so the detail panel follows the same shape rather than showing a list with
|
|
* one row highlighted.
|
|
*
|
|
* ── It renders the real page ──
|
|
*
|
|
* The preview is the site's own page at its own address, through the platform's dev proxy. Whatever
|
|
* the blocks end up being, this is what they will be judged against, and it is already true today.
|
|
*/
|
|
export const PageBuilderPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const { item: slug, part, piece } = useNav();
|
|
|
|
const devPort = useDevPort();
|
|
const { pages, announce } = usePages();
|
|
const { announceSections } = useSections();
|
|
// Cleared when this unmounts, or the nav goes on listing the last page's bands under whatever is
|
|
// selected next.
|
|
useEffect(() => () => announceSections(null), [announceSections]);
|
|
|
|
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
|
|
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
|
|
usePreviewable();
|
|
const { device } = useViewMode();
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sections, setSections] = useState<CanvasSection[]>([]);
|
|
|
|
/*
|
|
The handlers, through a ref; the DATA, as a dependency.
|
|
|
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
|
the current ones without being part of the identity.
|
|
*/
|
|
const handlersRef = useRef<{
|
|
add: () => void;
|
|
remove: (id: number) => void;
|
|
addComponent: (section: CanvasSection, column: number) => void;
|
|
editComponent: (component: CanvasComponent) => void;
|
|
setContent: (component: CanvasComponent, content: Record<string, unknown>) => Promise<void>;
|
|
removeComponent: (component: CanvasComponent) => void;
|
|
spaceSection: (section: CanvasSection) => void;
|
|
spaceComponent: (component: CanvasComponent) => void;
|
|
}>({
|
|
add: () => {},
|
|
remove: () => {},
|
|
addComponent: () => {},
|
|
editComponent: () => {},
|
|
setContent: async () => {},
|
|
removeComponent: () => {},
|
|
spaceSection: () => {},
|
|
spaceComponent: () => {},
|
|
});
|
|
const [adding, setAdding] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
/** Where a component is about to be added, while the kind is being chosen. */
|
|
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
|
|
/** The component being written, and where a new one would go. Null when the dialog is shut. */
|
|
/*
|
|
Which carousel a block shows, while it is being chosen.
|
|
|
|
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
|
|
there is nothing to draft and nothing to cancel out of afterwards. `carouselId` rides along so
|
|
reopening the picker can show where the block already stands.
|
|
*/
|
|
const [choosingCarousel, setChoosingCarousel] = useState<{
|
|
id: number | null;
|
|
sectionId: number;
|
|
column: number;
|
|
carouselId: number | null;
|
|
} | null>(null);
|
|
|
|
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
|
null,
|
|
);
|
|
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
|
|
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
|
|
/** Whose spacing is being changed. A section or one component, never both. */
|
|
const [spacing, setSpacing] = useState<
|
|
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
|
|
>(null);
|
|
/** The scale the API publishes, so this file holds no copy of it. */
|
|
const [steps, setSteps] = useState<number[]>([]);
|
|
/*
|
|
Bumped after a write, and passed to SitePreview to remount the frame.
|
|
|
|
The page is rendered by the SITE, from the database, so a new section only appears when the document
|
|
is fetched again. Nothing else can show it: the admin does not know how to draw a section, and if it
|
|
did it would be drawing a second implementation of the page.
|
|
*/
|
|
const [rendered, setRendered] = useState(0);
|
|
|
|
/*
|
|
The builder's chrome, painted into the rendered page.
|
|
|
|
Stable, because SitePreview holds it and re-runs it on every load. A new function each render would
|
|
be a new injection each render.
|
|
*/
|
|
/*
|
|
In VIEW, nothing is painted at all.
|
|
|
|
Not the chrome dimmed or the buttons hidden — no injection, so the frame holds exactly the document
|
|
the site served. That is what View has to mean here: outlines, labels and a drop zone are the
|
|
editor's, and a preview that keeps any of them is not showing you the page.
|
|
|
|
The check is INSIDE the callback and `mode` is a dependency, so switching to View hands SitePreview
|
|
a new function: its effect runs the previous paint's cleanup and then paints nothing. That is what
|
|
takes the chrome away, with no reload and no second code path.
|
|
*/
|
|
/*
|
|
The frame's own document, kept between paints.
|
|
|
|
It is how a draft reaches the page: the editor writes each change into the component's node rather
|
|
than waiting for a save and a reload. Held in a ref because it is not state — nothing re-renders
|
|
when the document changes, and the effect below reads whatever is current.
|
|
*/
|
|
const frameDoc = useRef<Document | null>(null);
|
|
|
|
/*
|
|
Picking a block writes the fourth path segment; this opens the editor for whatever it names.
|
|
|
|
Through a ref for the same reason the other handlers are: `onDocument` must depend on the sections
|
|
and nothing else, or it repaints on every render.
|
|
*/
|
|
const navigate = useNavigate();
|
|
const navigateRef = useRef<(component: CanvasComponent) => void>(() => {});
|
|
navigateRef.current = (component) => {
|
|
if (!page) return;
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
navigate(href('pages', pageParam(page), String(owner.id), String(component.id)), { replace: true });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!piece) {
|
|
// The band row was pressed, or the page was. Nothing is selected, so nothing should be open.
|
|
setEditing(null);
|
|
return;
|
|
}
|
|
const found = sections.flatMap((section) => section.components).find((one) => String(one.id) === piece);
|
|
if (!found) return;
|
|
|
|
/*
|
|
Selecting a block shows its settings. It does not open a picker.
|
|
|
|
`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
|
|
at you before you had said you wanted one — those kinds get a button in the settings instead.
|
|
|
|
Free text is different: its editor is inline in the same column, so opening it on selection is
|
|
the same act as showing the settings.
|
|
*/
|
|
if (found.kind === 'free-text') editComponentRef.current(found);
|
|
// Whatever was open belonged to the block that was selected before this one.
|
|
else setEditing(null);
|
|
}, [piece, sections]);
|
|
|
|
/*
|
|
The band named by the third URL segment, brought into view.
|
|
|
|
That segment is what the nav's rows under the open page write, and this is what makes them do
|
|
something. `rendered` is a dependency as well as `part`: the frame is replaced on every write, and
|
|
a scroll issued against the old document would be lost — this way the position survives a save.
|
|
|
|
The band is found by `data-section-id`, the marker the site already carries for the admin.
|
|
*/
|
|
/*
|
|
Read inside `onDocument`, which runs against the document that has just LOADED.
|
|
|
|
The effect below cannot do that job: bumping `rendered` runs it immediately, while `frameDoc` still
|
|
points at the outgoing document — so it scrolled the page that was about to be replaced, and the
|
|
new one arrived at the top. Every write from the editor did that: reorder a carousel, lose your
|
|
place.
|
|
*/
|
|
const partRef = useRef<string | null>(part);
|
|
partRef.current = part;
|
|
|
|
useEffect(() => {
|
|
// A different band chosen, with no reload. `smooth`, because you are watching it move.
|
|
if (!part) return;
|
|
const band = frameDoc.current?.querySelector(`[data-section-id="${CSS.escape(part)}"]`);
|
|
band?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, [part]);
|
|
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
frameDoc.current = frameDocument;
|
|
|
|
/*
|
|
The measure, here as well as on Layout.
|
|
|
|
Where the content column ends is invisible until something is placed against it, and this is
|
|
the screen where things are placed. It also answers the question a negative margin asks: a
|
|
block escaping the gutter has to be seen escaping it, against the line it crossed.
|
|
*/
|
|
/*
|
|
Back to where you were, in the document that just arrived.
|
|
|
|
`auto` and not `smooth`: this is a fresh document and there is nothing to watch move — a smooth
|
|
scroll from the top would animate past everything above the band for no reason.
|
|
*/
|
|
if (partRef.current) {
|
|
frameDocument
|
|
.querySelector(`[data-section-id="${CSS.escape(partRef.current)}"]`)
|
|
?.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
}
|
|
|
|
const measure = paintMeasure(frameDocument);
|
|
const selection = paintSelection(frameDocument, {
|
|
sections,
|
|
onAddSection: () => handlersRef.current.add(),
|
|
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
|
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
|
|
/*
|
|
Selection goes through the URL, not straight to the handler.
|
|
|
|
The nav's rows write the same address, so both ways of picking a block are the same act and
|
|
neither can disagree with the other about what is selected. The effect below is what opens
|
|
the editor, whichever wrote it.
|
|
*/
|
|
onEditComponent: (component) => navigateRef.current(component),
|
|
// Nothing to edit, so no button. See PLACES_ONLY.
|
|
canEditComponent: (component) => !PLACES_ONLY.includes(component.kind),
|
|
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
|
|
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
|
|
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
|
|
});
|
|
|
|
return () => {
|
|
selection();
|
|
measure();
|
|
};
|
|
},
|
|
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
|
|
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
|
|
[sections],
|
|
);
|
|
|
|
/*
|
|
The handlers, through a ref; the DATA, as a dependency.
|
|
|
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
|
the current ones without being part of the identity.
|
|
*/
|
|
const deleteSection = async (sectionId: number) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${sectionId}`);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const createSection = async (values: { name: string; layout: SectionLayout }) => {
|
|
if (!page) return;
|
|
setCreating(true);
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections`, values);
|
|
setAdding(false);
|
|
// The site has to re-render for the new band to exist. See `rendered` above.
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
/*
|
|
The list, if nobody has announced it yet.
|
|
|
|
Landing here directly — a bookmark, a reload, a URL typed with `?page=4` — means Pages has never
|
|
rendered and the channel is empty. So this fetches once and announces, which also fills the nav.
|
|
Arriving from the nav, `pages` is already there and nothing is requested.
|
|
*/
|
|
useEffect(() => {
|
|
if (pages) return;
|
|
void clientRef.current
|
|
.get<{ data: Page[] }>('/fofos-admin/pages')
|
|
.then((response) => announce(response.data))
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
}, [pages, announce]);
|
|
|
|
const page = pageFromParam(pages, slug);
|
|
|
|
/*
|
|
The page's sections, which the canvas needs to name and outline what it finds in the document.
|
|
|
|
Fetched here rather than read out of the DOM. The markup says which id a band came from and nothing
|
|
else — no name, no layout — and inferring the layout from the classes would be the admin reading the
|
|
site's stylesheet, which is exactly the coupling all of this avoids.
|
|
*/
|
|
useEffect(() => {
|
|
if (!page) return;
|
|
void clientRef.current
|
|
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
|
.then((response) => {
|
|
setSections(response.data);
|
|
// The nav draws these as rows under the open page — see useSections for why it is announced
|
|
// rather than fetched a second time.
|
|
announceSections(
|
|
response.data.map((section) => ({
|
|
id: section.id,
|
|
name: section.name,
|
|
components: section.components.map((component) => ({ id: component.id, kind: component.kind })),
|
|
})),
|
|
);
|
|
if (response.steps) setSteps(response.steps);
|
|
})
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
}, [page, rendered]);
|
|
|
|
// Assigned on every render, after both functions above exist, so the ref always holds the current
|
|
// pair without either being part of `onDocument`'s identity.
|
|
/*
|
|
Add component asks WHAT, even though only one kind can be chosen today.
|
|
|
|
It opened the text editor straight away, on the reasoning that one option is not a choice. That
|
|
optimised the state of the app rather than its shape: somebody adding a component could not see what
|
|
a component is or that there will be others, and the day the second kind lands the interaction
|
|
changes underneath them. The picker names the ones that are coming, the way the nav does.
|
|
*/
|
|
const addComponent = (section: CanvasSection, column: number) =>
|
|
setChoosing({ sectionId: section.id, column });
|
|
|
|
/*
|
|
A block that holds nothing is CREATED on the spot.
|
|
|
|
No dialog, because there is nothing to fill in: the contact form is the site's form and the partner
|
|
row is the partner table. Asking for a confirmation with no field on it would be a step that exists
|
|
only to be got past — the same argument the picker itself answers the other way, where there IS a
|
|
choice to make.
|
|
*/
|
|
const createEmpty = async (kind: ComponentKind, at: { sectionId: number; column: number }) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections/${at.sectionId}/components`, {
|
|
kind,
|
|
column: at.column,
|
|
content: {},
|
|
});
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const chooseKind = (kind: ComponentKind) => {
|
|
if (!choosing) return;
|
|
const at = choosing;
|
|
setChoosing(null);
|
|
// 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 === 'media') setPicking({ id: null, ...at });
|
|
if (kind === 'carousel') setChoosingCarousel({ id: null, ...at, carouselId: null });
|
|
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
|
|
};
|
|
|
|
/*
|
|
A media block is a PATH, saved as soon as it is chosen.
|
|
|
|
Free text needs a dialog because writing is a thing people do halfway; picking a file is one act
|
|
with one outcome, so there is nothing to draft and nothing to cancel out of afterwards.
|
|
|
|
Photograph or video is not asked. The file already knows, and the site reads it off the extension
|
|
the way a carousel item always has — a kind stored here would be a second answer able to disagree.
|
|
*/
|
|
const saveVideo = async (url: string) => {
|
|
if (!page || !picking) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${picking.sectionId}/components`;
|
|
setPicking(null);
|
|
setError(null);
|
|
try {
|
|
if (picking.id === null) {
|
|
await clientRef.current.post(base, { kind: 'media', column: picking.column, content: { url } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${picking.id}`, { content: { url } });
|
|
}
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/*
|
|
A carousel block is a REFERENCE, saved as soon as the set is chosen — see CarouselPicker.
|
|
|
|
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
|
|
value. The two are left as two functions rather than one generic saver, because what they store
|
|
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
|
|
*/
|
|
const saveCarousel = async (carouselId: number) => {
|
|
if (!page || !choosingCarousel) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${choosingCarousel.sectionId}/components`;
|
|
const target = choosingCarousel;
|
|
setChoosingCarousel(null);
|
|
setError(null);
|
|
try {
|
|
if (target.id === null) {
|
|
await clientRef.current.post(base, { kind: 'carousel', column: target.column, content: { carouselId } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${target.id}`, { content: { carouselId } });
|
|
}
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/* Held so the effect that reads the URL can call it without depending on its identity. */
|
|
const editComponentRef = useRef<(component: CanvasComponent) => void>(() => {});
|
|
|
|
/**
|
|
* Write a block's whole `content`.
|
|
*
|
|
* Whole, because the API replaces it rather than merging — so every caller sends what is already
|
|
* there alongside what it is changing. That is the contract for a jsonb column somebody can put
|
|
* anything in: a merge would need the server to know which keys are meaningful, and it does not.
|
|
*/
|
|
/** Every id, in order — which is what makes two bands claiming a position impossible. */
|
|
const reorderSections = async (ids: number[]) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.put(`/fofos-admin/pages/${page.id}/sections/order`, { ids });
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
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;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.patch(
|
|
`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`,
|
|
{ content },
|
|
);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const editComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
|
|
// Edit means whatever editing THIS kind means. The canvas draws one button; which door it opens is
|
|
// decided here, where the kinds are known.
|
|
// `video` too — no row holds it since the rename, but one written by something older should
|
|
// still be editable rather than open nothing.
|
|
if (component.kind === 'media' || component.kind === 'video') {
|
|
setPicking({ id: component.id, sectionId: owner.id, column: component.column });
|
|
return;
|
|
}
|
|
|
|
if (component.kind === 'carousel') {
|
|
const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId;
|
|
setChoosingCarousel({
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
column: component.column,
|
|
carouselId: typeof held === 'number' ? held : null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
|
|
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
|
|
};
|
|
|
|
/*
|
|
The draft, written into the component's node as it is typed.
|
|
|
|
The node's own children are replaced and the injected tools put back — those are the admin's and
|
|
live inside the component, so an innerHTML that forgot them would take the Edit button away
|
|
mid-edit.
|
|
|
|
Not sanitised here. The server cleans on write, so what is on screen while typing can be marginally
|
|
more permissive than what is stored — and the difference is visible the moment it is saved, which
|
|
is the right place to find out.
|
|
*/
|
|
useEffect(() => {
|
|
const doc = frameDoc.current;
|
|
if (!doc || !editing?.id) return;
|
|
|
|
const node = doc.querySelector<HTMLElement>(`[data-fofos-component="${editing.id}"]`);
|
|
if (!node) return;
|
|
|
|
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
|
|
/*
|
|
The BLOCK inside, when the component has a heading.
|
|
|
|
A titled component renders as a wrapper — heading, then `[data-block]` — and writing into the
|
|
wrapper would replace the heading with the paragraph as somebody typed. Untitled ones are not
|
|
wrapped, so the node itself is the block.
|
|
*/
|
|
(node.querySelector<HTMLElement>('[data-block]') ?? node).innerHTML = editing.html;
|
|
if (tools) node.append(tools);
|
|
}, [editing?.id, editing?.html]);
|
|
|
|
const saveText = async () => {
|
|
if (!page || !editing) return;
|
|
const html = editing.html;
|
|
setCreating(true);
|
|
setError(null);
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
|
|
try {
|
|
if (editing.id === null) {
|
|
await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } });
|
|
}
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const deleteComponent = async (component: CanvasComponent) => {
|
|
if (!page) return;
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/*
|
|
Spacing is written IMMEDIATELY, like adding and deleting.
|
|
|
|
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
|
|
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
|
|
already act.
|
|
*/
|
|
const saveSpacing = async (padding: Padding) => {
|
|
if (!page || !spacing) return;
|
|
setSpacing({ ...spacing, padding });
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
|
|
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
|
|
try {
|
|
await clientRef.current.patch(url, { padding });
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const spaceSection = (section: CanvasSection) =>
|
|
setSpacing({
|
|
kind: 'section',
|
|
id: section.id,
|
|
sectionId: section.id,
|
|
label: section.name,
|
|
padding: (section.padding ?? {}) as Padding,
|
|
});
|
|
|
|
const spaceComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
setSpacing({
|
|
kind: 'component',
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
label: LABELS[component.kind] ?? 'Text',
|
|
padding: (stored?.padding ?? {}) as Padding,
|
|
});
|
|
};
|
|
|
|
const [, setEditor] = useEditorSlot();
|
|
const setEditorRef = useRef(setEditor);
|
|
setEditorRef.current = setEditor;
|
|
const saveSpacingRef = useRef(saveSpacing);
|
|
saveSpacingRef.current = saveSpacing;
|
|
const saveTextRef = useRef(saveText);
|
|
saveTextRef.current = saveText;
|
|
|
|
/*
|
|
Hand the open editor to the editor panel.
|
|
|
|
Announced rather than rendered, because the panel is a SIBLING — see useEditorSlot. The dependency
|
|
list is everything either editor DISPLAYS, which is the part that is easy to get wrong: an element
|
|
captures the props it was built with, so one left out here shows up as a control that has quietly
|
|
stopped responding rather than as an error.
|
|
|
|
The handlers are in refs for that reason. They are rebuilt every render, and depending on them
|
|
would re-announce on every keystroke; leaving them out without refs would announce a stale one.
|
|
*/
|
|
useEffect(() => {
|
|
if (spacing) {
|
|
setEditorRef.current(
|
|
<SpacingEditor
|
|
label={spacing.label}
|
|
padding={spacing.padding}
|
|
device={device}
|
|
steps={steps}
|
|
busy={false}
|
|
onChange={(padding) => void saveSpacingRef.current(padding)}
|
|
onClose={() => setSpacing(null)}
|
|
/>,
|
|
);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
The selected block's heading, above whatever editor its kind has.
|
|
|
|
Announced together, because they are one selection: the heading belongs to the block and the
|
|
editor is what is inside it. A block with no editor gets the heading alone, which is why every
|
|
block is selectable now.
|
|
*/
|
|
const owner = piece ? sections.find((one) => one.components.some((c) => String(c.id) === piece)) : null;
|
|
const block = owner?.components.find((c) => String(c.id) === piece);
|
|
|
|
if (block && owner && page) {
|
|
setEditorRef.current(
|
|
<div className="flex flex-col gap-4">
|
|
<BlockSettings
|
|
pageId={page.id}
|
|
sectionId={owner.id}
|
|
block={block}
|
|
steps={steps}
|
|
onSaved={() => setRendered((n) => n + 1)}
|
|
/>
|
|
{/* Only when it belongs to THIS block. A stale one showed the previous block's words under
|
|
the new block's title. */}
|
|
{editing?.id === block.id && (
|
|
<FreeTextEditor
|
|
html={editing.html}
|
|
busy={creating}
|
|
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
|
|
onCancel={() => {
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
}}
|
|
onSave={() => void saveTextRef.current()}
|
|
/>
|
|
)}
|
|
|
|
{/*
|
|
The kinds whose contents are chosen rather than typed.
|
|
|
|
A button rather than the modal opening itself, so picking the block and changing what is in
|
|
it are two acts — see the note in the selection effect.
|
|
*/}
|
|
{/*
|
|
Partners are the SITE's, not this page's — the block places the list rather than holding
|
|
one. So this edits the singleton, and every page showing them changes together.
|
|
*/}
|
|
{block.kind === 'partners' && <PartnersEditor onChanged={() => setRendered((n) => n + 1)} />}
|
|
|
|
{block.kind === 'carousel' && (
|
|
<div className="flex flex-col gap-3">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 self-start text-xs"
|
|
onClick={() => handlersRef.current.editComponent(block)}
|
|
>
|
|
Choose carousel…
|
|
</Button>
|
|
|
|
{/*
|
|
The carousel's own contents, here rather than a link to the Carousels section.
|
|
|
|
A carousel block on a page IS a carousel, and being sent elsewhere to reorder it means
|
|
losing the page you were arranging it for. `onChanged` reloads the frame, because the
|
|
page draws this set and has just been given a different one.
|
|
*/}
|
|
{typeof block.content?.carouselId === 'number' && (
|
|
<CarouselItems carouselId={block.content.carouselId} onChanged={() => setRendered((n) => n + 1)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(block.kind === 'media' || block.kind === 'video') && (
|
|
<div className="flex flex-col gap-3">
|
|
{/*
|
|
The shape of the slot, and the framing inside it.
|
|
|
|
The ratio becomes a slot shape and the site serves a rendition cut to it, so a band
|
|
shorter than its photograph costs FEWER bytes rather than more.
|
|
|
|
With a shape chosen, `MediaField` replaces the plain thumbnail: it draws the picture in
|
|
that shape and lets somebody drag and zoom to say which part shows. Without one there
|
|
is nothing to frame into — the whole picture is used — so framing would be a control
|
|
that does nothing.
|
|
|
|
`content` is replaced wholesale by the API, so every write sends what is already there
|
|
alongside the new value.
|
|
*/}
|
|
{!/\.(mp4|mov|webm|m4v)$/i.test(block.content?.url ?? '') && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs">Shape</label>
|
|
<select
|
|
value={shapeOf(block) ?? ''}
|
|
onChange={(event) => {
|
|
const chosen = SHAPES.find((one) => one.value === event.currentTarget.value);
|
|
void handlersRef.current.setContent(block, {
|
|
...block.content,
|
|
ratio: chosen?.ratio ?? undefined,
|
|
// A crop is a rectangle of a SHAPE. Changing the shape makes it meaningless,
|
|
// and keeping it would frame the new one by numbers chosen for the old.
|
|
crop: undefined,
|
|
});
|
|
}}
|
|
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
|
>
|
|
{SHAPES.map((one) => (
|
|
<option key={one.value || 'own'} value={one.value}>
|
|
{one.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{shapeOf(block) ? (
|
|
<BlockMedia
|
|
url={block.content?.url ?? null}
|
|
crop={block.content?.crop ?? null}
|
|
aspect={shapeOf(block)!}
|
|
onUrl={(url) => void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })}
|
|
onCrop={(crop) =>
|
|
void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined })
|
|
}
|
|
/>
|
|
) : (
|
|
<MediaBlockSummary
|
|
url={block.content?.url ?? null}
|
|
onChoose={() => handlersRef.current.editComponent(block)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (editing) {
|
|
setEditorRef.current(
|
|
<FreeTextEditor
|
|
html={editing.html}
|
|
busy={creating}
|
|
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
|
|
/*
|
|
Cancelling reloads the frame rather than putting the old markup back by hand. The node has
|
|
been written into on every keystroke, and restoring it correctly means remembering what was
|
|
there before the first one — a reload is the same answer with nothing to get wrong.
|
|
*/
|
|
onCancel={() => {
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
}}
|
|
onSave={() => void saveTextRef.current()}
|
|
/>,
|
|
);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
A band selected and nothing inside it: its own settings.
|
|
|
|
Last, so a block or a spacing editor wins — those are things you opened deliberately, and the
|
|
band is merely still selected underneath them.
|
|
*/
|
|
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)}
|
|
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;
|
|
}
|
|
|
|
/*
|
|
Nothing selected: the page itself, as the list of bands it is made of.
|
|
|
|
Last, after the band and the block, because those are things you opened deliberately. This is
|
|
what is there when you have not — and it is where adding, removing and reordering a band live,
|
|
now that the bars painted onto the page are gone.
|
|
*/
|
|
if (page) {
|
|
setEditorRef.current(
|
|
<PageStructure
|
|
slug={slug ?? ''}
|
|
sections={sections}
|
|
busy={creating}
|
|
onAdd={() => handlersRef.current.add()}
|
|
onDelete={(section) => handlersRef.current.remove(section.id)}
|
|
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;
|
|
}
|
|
|
|
setEditorRef.current(null);
|
|
}, [spacing, editing, creating, device, steps, part, piece, sections, page, slug]);
|
|
|
|
// Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at.
|
|
useEffect(() => () => setEditorRef.current(null), []);
|
|
|
|
// Both refs are assigned during render, so the effects above always call the current closures.
|
|
editComponentRef.current = editComponent;
|
|
|
|
handlersRef.current = {
|
|
add: () => setAdding(true),
|
|
remove: (sectionId: number) => void deleteSection(sectionId),
|
|
addComponent,
|
|
editComponent,
|
|
setContent,
|
|
removeComponent: (component) => void deleteComponent(component),
|
|
spaceSection,
|
|
spaceComponent,
|
|
};
|
|
|
|
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
|
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
|
|
|
|
if (!page) {
|
|
// A page that was deleted in another panel, or an id somebody typed. Said plainly rather than
|
|
// redirected: a silent bounce to the list leaves you wondering whether you clicked the wrong thing.
|
|
return <div className="p-4 text-xs text-muted-foreground">That page no longer exists.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-3 p-4">
|
|
<div className="flex shrink-0 items-baseline gap-2">
|
|
<h3 className="text-sm font-medium">{page.title}</h3>
|
|
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
|
</div>
|
|
|
|
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
|
|
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
|
|
{/* `reloadToken` moves on every write, which refetches the document AND re-runs `onDocument` —
|
|
so the decoration is redrawn against the sections that now exist. */}
|
|
{/* A TEMPLATE is previewed against a real instance — its own slug is not an address. See
|
|
preview_slug, which the server fills from the first thing the template draws. */}
|
|
<SitePreview
|
|
port={devPort}
|
|
path={page.is_template ? (page.preview_slug ?? page.slug) : page.slug}
|
|
onDocument={onDocument}
|
|
reloadToken={rendered}
|
|
/>
|
|
|
|
<AddSectionDialog
|
|
open={adding}
|
|
busy={creating}
|
|
onCancel={() => setAdding(false)}
|
|
onCreate={(values) => void createSection(values)}
|
|
/>
|
|
|
|
<AddComponentDialog
|
|
open={choosing !== null}
|
|
onCancel={() => setChoosing(null)}
|
|
onChoose={chooseKind}
|
|
// Which decides whether the subject blocks are offered — they render nothing anywhere else.
|
|
template={page?.is_template}
|
|
/>
|
|
|
|
<MediaPicker
|
|
open={picking !== null}
|
|
onClose={() => setPicking(null)}
|
|
onPick={(path) => void saveVideo(path)}
|
|
title="Choose a photograph or a video"
|
|
/>
|
|
|
|
<CarouselPicker
|
|
open={choosingCarousel !== null}
|
|
onClose={() => setChoosingCarousel(null)}
|
|
onPick={(carouselId) => void saveCarousel(carouselId)}
|
|
selected={choosingCarousel?.carouselId ?? null}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
};
|