Files
fofos-admin/web/sections/AboutPanel.tsx
T
pastilhasandClaude Opus 5 d1e79d95db text over the header photograph, placed by dragging it
The wireframe puts two blocks of text on the header picture. The page had one `phrase`, drawn under it.

`header_overlays` is a jsonb list — `[{ text, x, y, size, color, align }]` — rather than a pair of
`header_text_1_*` columns. The design asks for two today; a third is then data rather than a migration,
and nothing has to decide what an empty second block means. `headerPhrase` is left alone: folding them
together would silently move somebody's existing copy on top of a photograph.

Everything is relative and has to be. x and y are percentages of the picture, so a position means the
same thing on a phone and a desktop. `size` is a percentage of the picture's WIDTH, because a headline
set at 40px is a headline on a laptop and a caption on a large screen. The preview renders that with
container query units against the picture's own box, which is exactly what the percentage means — the
page will do the same, so the two cannot drift.

The editor is the picture with the text on it, at the shape the page uses, because where a line sits on
a photograph is not a decision anybody can make from two number fields.

The API clamps every field rather than rejecting: a position into the picture, a size into something
legible, the colour to a hex triple. A caller sending nonsense gets a usable overlay instead of a 400,
and the page can never be handed a position that puts text off the edge of its own header.

Pushed to both databases and verified by querying information_schema rather than by reading db:push's
exit code, which is documented to lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:19:30 +01:00

220 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { RichText } from '../RichText';
import { MediaField } from '../MediaField';
import { OverlayEditor, type Overlay } from '../OverlayEditor';
import { useSaveTarget } from '../SaveTarget';
/**
* About Us — four bands, in the order the page shows them.
*
* The sitemap's shape, exactly: a HEADER of a photograph and a phrase, then ABOUT US, then PRODUTO
* with a video beside its text, then MANIFESTO. Nothing here invents a field the page does not render
* and nothing the page renders is missing.
*
* ── It edits DEVELOPMENT ──
*
* Like every section here. An edit lands on the development server immediately and becomes public when
* somebody presses Publish; production is a directory of static files with no API to write to.
*
* ── One Save, in the panel's bar ──
*
* There were four, one per band, because the API treats an absent key as "leave it alone" and a Save
* carrying everything meant a half-written manifesto shipping with a photograph somebody meant to
* change. With a single Save that is the model rather than an accident: one press writes the page you
* are looking at.
*
* `dirty` is a comparison against what the server last returned, not a flag set by every keystroke. A
* flag says "something was typed" and stays true after you undo it by hand; a comparison says "this
* differs from what is stored", which is the question the button is actually answering.
*
* ── The server owns the shape ──
*
* Every PATCH answers with the whole payload and this never patches its own state from what it sent.
* The server clears the header's crop when the header photograph changes, and a client keeping its own
* copy would go on showing a framing that had already been discarded.
*/
type About = {
header: {
url: string | null;
crop: string | null;
fit: string | null;
phrase: string | null;
/** Text laid over the picture — the wireframe's two blocks. */
overlays: Overlay[];
};
about_text: string | null;
product_video_url: string | null;
product_text: string | null;
manifesto_text: string | null;
};
export const AboutPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [about, setAbout] = useState<About | null>(null);
const [headerUrl, setHeaderUrl] = useState('');
const [phrase, setPhrase] = useState('');
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
const [overlays, setOverlays] = useState<Overlay[]>([]);
const [aboutText, setAboutText] = useState('');
const [productVideo, setProductVideo] = useState('');
const [productText, setProductText] = useState('');
const [manifesto, setManifesto] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
/** Every write answers with the whole payload, so this is the only place state is set. */
const apply = useCallback((data: About) => {
setAbout(data);
setHeaderUrl(data.header.url ?? '');
setHeaderCrop(data.header.crop);
setOverlays(data.header.overlays ?? []);
setPhrase(data.header.phrase ?? '');
setAboutText(data.about_text ?? '');
setProductVideo(data.product_video_url ?? '');
setProductText(data.product_text ?? '');
setManifesto(data.manifesto_text ?? '');
}, []);
const load = useCallback(async () => {
try {
apply((await clientRef.current.get<{ data: About }>('/fofos-admin/about')).data);
} catch (failure) {
setError(readableError(failure));
}
}, [apply]);
useEffect(() => {
void load();
}, [load]);
/** Empty is null, not "". A field somebody cleared and one never filled are the same thing. */
const orNull = (value: string) => value.trim() || null;
/** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */
const edited = {
header_url: orNull(headerUrl),
header_crop: headerCrop,
header_overlays: overlays,
header_phrase: orNull(phrase),
about_text: orNull(aboutText),
product_video_url: orNull(productVideo),
product_text: orNull(productText),
manifesto_text: orNull(manifesto),
};
const stored = about && {
header_url: about.header.url,
header_crop: about.header.crop,
header_overlays: about.header.overlays ?? [],
header_phrase: about.header.phrase,
about_text: about.about_text,
product_video_url: about.product_video_url,
product_text: about.product_text,
manifesto_text: about.manifesto_text,
};
const dirty = Boolean(stored) && JSON.stringify(edited) !== JSON.stringify(stored);
const save = async () => {
setBusy('save');
setError(null);
try {
apply((await clientRef.current.patch<{ data: About }>('/fofos-admin/about', edited)).data);
flashSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(null);
}
};
useSaveTarget(dirty, busy !== null, save);
if (!about) {
return (
<div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>
);
}
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
{/* Confirmation only. The order of the bands is the order of the page, which the bands
themselves already say — a sentence explaining that was a caption for something visible. */}
{saved && <p className="text-xs text-success">Saved</p>}
{/* ── HEADER: a photograph and a phrase over it ─────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Header</h3>
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
have to agree or the admin shows a shape the site does not have, which is worse than showing
no shape at all: it looks decided. */}
{/* The picture is the editor's canvas: the overlays are positioned against the same box the
page draws, so what is arranged here is what is published. */}
<OverlayEditor overlays={overlays} onChange={setOverlays}>
<MediaField
value={headerUrl || null}
onChange={(path) => {
setHeaderUrl(path ?? '');
// A framing belongs to the picture it was made for. Keeping it across a change would
// apply one photograph's decision to another one's composition.
setHeaderCrop(null);
}}
crop={headerCrop}
onCropChange={setHeaderCrop}
aspect="21/9"
label="header photograph"
/>
</OverlayEditor>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Phrase</label>
<Input value={phrase} onChange={(event) => setPhrase(event.currentTarget.value)} />
</div>
</section>
{/* ── ABOUT US ─────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">About us</h3>
<RichText value={aboutText} onChange={setAboutText} placeholder="Tell people who you are." />
</section>
{/* ── PRODUTO: a video beside its text ─────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Product</h3>
{/* 16/9, matching the `aspect-video` the product block draws. */}
<MediaField
value={productVideo || null}
onChange={(path) => setProductVideo(path ?? '')}
aspect="16/9"
kind="video"
label="product video"
/>
<RichText value={productText} onChange={setProductText} placeholder="What the product is." />
</section>
{/* ── MANIFESTO ────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Manifesto</h3>
<RichText value={manifesto} onChange={setManifesto} placeholder="The manifesto." />
</section>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};