From 0dce8fcb3c508f3c8278febde3cd79e3ecc7c055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 30 Aug 2026 22:33:45 +0100 Subject: [PATCH] About Us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bands in the order the page shows them, which is 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. No schema change and no API change — `about_content` already carries every one of those columns and `aboutRouter` already reads and writes them. This is the screen that was missing, not the data. One save per band, the rule Home follows and for the same reason: `take()` 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. Both media slots go through MediaPicker rather than a text field. A path somebody types is a path that is wrong by one character and renders as a broken image. One picker serves both, with `picking` naming which slot asked — two would be two copies of a modal that already knows how to browse originals/. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailPanel.tsx | 2 + web/sections.ts | 2 +- web/sections/AboutPanel.tsx | 224 ++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 web/sections/AboutPanel.tsx diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index c584914..0835f36 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -4,6 +4,7 @@ import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { HomePanel } from './sections/HomePanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; +import { AboutPanel } from './sections/AboutPanel'; import { RecordsPanel } from './sections/RecordsPanel'; import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records'; import { MessagesPanel } from './MessagesPanel'; @@ -43,6 +44,7 @@ import { useReady } from './useReady'; */ const PANELS: Partial React.JSX.Element>> = { home: HomePanel, + about: AboutPanel, /* Three sections, one panel, configured — the same split the site's API already makes between `records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries. diff --git a/web/sections.ts b/web/sections.ts index 1e486e3..0537d91 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -89,7 +89,7 @@ export const SECTIONS: Section[] = [ icon: Info, hint: 'The header photograph and the copy', path: '/fofos-admin/about', - built: false, + built: true, requires: 'connection', }, { diff --git a/web/sections/AboutPanel.tsx b/web/sections/AboutPanel.tsx new file mode 100644 index 0000000..fbb448b --- /dev/null +++ b/web/sections/AboutPanel.tsx @@ -0,0 +1,224 @@ +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); + }} + /> +
+ ); +};