import { useCallback, useEffect, useRef, useState } from 'react'; import { FolderOpen, 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 { MediaPicker } from '../MediaPicker'; /** * 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 per band ── * * The same rule Home follows, for the same reason. `take()` on the server treats an absent key as * "leave it alone", so a single Save carrying every field would let a half-finished manifesto ride * along with a photograph somebody meant to change. Four bands, four independent saves. * * ── 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 }; about_text: string | null; product_video_url: string | null; product_text: string | null; manifesto_text: string | null; }; /** Which media slot the picker is open for, or null. The two cannot be open at once. */ type Picking = 'header' | 'product' | 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 [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(); const [picking, setPicking] = useState(null); /** 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 ?? ''); 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]); const send = async (label: string, body: Record) => { setBusy(label); setError(null); try { apply((await clientRef.current.patch<{ data: About }>('/fofos-admin/about', body)).data); flashSaved(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(null); } }; /** Empty is null, not "". A field somebody cleared and one never filled are the same thing. */ const orNull = (value: string) => value.trim() || null; if (!about) { return (
{error ?? 'Loading…'}
); } const saveButton = (label: string, onClick: () => void) => (
{saved && busy === null && Saved}
); return (

The About page, in the order it is shown: a header, the text about you, the product, and the manifesto.

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

Header

The photograph at the top, and the line over it.

{/* Chosen from originals/, never typed. The same picker Home's hero uses — a path somebody types is a path that is wrong by one character and renders as a broken image. */}
setPhrase(event.currentTarget.value)} />
{saveButton('header', () => void send('header', { header_url: orNull(headerUrl), header_phrase: orNull(phrase) }), )}
{/* ── ABOUT US ─────────────────────────────────────────────────────────────────────────── */}

About us

Who you are.

{saveButton('about', () => void send('about', { about_text: orNull(aboutText) }))}
{/* ── PRODUTO: a video beside its text ─────────────────────────────────────────────────── */}

Product

What you make, and a video of it being made.

{saveButton('product', () => void send('product', { product_video_url: orNull(productVideo), product_text: orNull(productText) }), )}
{/* ── MANIFESTO ────────────────────────────────────────────────────────────────────────── */}

Manifesto

What you stand for.

{saveButton('manifesto', () => void send('manifesto', { manifesto_text: orNull(manifesto) }))}
{error &&

{error}

} {/* One picker, two slots. `picking` names which one asked, so the chosen path lands in the right field. Two pickers would be two copies of a modal that already knows how to browse originals/. */} setPicking(null)} title={picking === 'product' ? 'Choose the product video' : 'Choose the header photograph'} onPick={(path) => { if (picking === 'product') setProductVideo(path); else setHeaderUrl(path); setPicking(null); }} />
); };