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(null); const [headerUrl, setHeaderUrl] = useState(''); const [phrase, setPhrase] = useState(''); const [headerCrop, setHeaderCrop] = useState(null); const [overlays, setOverlays] = useState([]); const [aboutText, setAboutText] = useState(''); const [productVideo, setProductVideo] = useState(''); const [productText, setProductText] = useState(''); const [manifesto, setManifesto] = useState(''); const [busy, setBusy] = useState(null); const [error, setError] = useState(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 (
{error ?? 'Loading…'}
); } return (
{/* 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 &&

Saved

} {/* ── HEADER: a photograph and a phrase over it ─────────────────────────────────────────── */}

Header

{/* 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. */} { 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" />
setPhrase(event.currentTarget.value)} />
{/* ── ABOUT US ─────────────────────────────────────────────────────────────────────────── */}

About us

{/* ── PRODUTO: a video beside its text ─────────────────────────────────────────────────── */}

Product

{/* 16/9, matching the `aspect-video` the product block draws. */} setProductVideo(path ?? '')} aspect="16/9" kind="video" label="product video" />
{/* ── MANIFESTO ────────────────────────────────────────────────────────────────────────── */}

Manifesto

{error &&

{error}

}
); };