Every collection has a hero and every folder a _1, so a bare filename says which file only to somebody who already knows. Not truncated either: truncate cuts the end, which is the wrong half of a path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1071 lines
44 KiB
TypeScript
1071 lines
44 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 { 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 })
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => handlersRef.current.editComponent(block)}
|
|
>
|
|
Choose media…
|
|
</Button>
|
|
{/*
|
|
The whole path from `originals/`, not the filename.
|
|
|
|
Half these files are called the same thing — every collection has a hero, every
|
|
folder a `_1` — so a bare name says which picture only when you already know.
|
|
`truncate` cuts the END, which is exactly backwards for a path, so it is not used
|
|
here: the row wraps and the folder is what you read first.
|
|
*/}
|
|
<span className="min-w-0 flex-1 font-mono text-[11px] break-all text-muted-foreground">
|
|
{block.content?.url ?? 'Nothing chosen'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* A video shows its POSTER rather than playing: the question is which file this is. */}
|
|
{block.content?.url && (
|
|
<img
|
|
src={
|
|
/\.(mp4|mov|webm|m4v)$/i.test(block.content.url)
|
|
? posterUrl(block.content.url)
|
|
: thumbUrl(block.content.url, 640)
|
|
}
|
|
alt=""
|
|
className="w-full rounded border border-border"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|