About Us
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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { AdministrationPanel } from './AdministrationPanel';
|
|||||||
import { MediaPanel } from './MediaPanel';
|
import { MediaPanel } from './MediaPanel';
|
||||||
import { HomePanel } from './sections/HomePanel';
|
import { HomePanel } from './sections/HomePanel';
|
||||||
import { GalleriesPanel } from './sections/GalleriesPanel';
|
import { GalleriesPanel } from './sections/GalleriesPanel';
|
||||||
|
import { AboutPanel } from './sections/AboutPanel';
|
||||||
import { RecordsPanel } from './sections/RecordsPanel';
|
import { RecordsPanel } from './sections/RecordsPanel';
|
||||||
import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records';
|
import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records';
|
||||||
import { MessagesPanel } from './MessagesPanel';
|
import { MessagesPanel } from './MessagesPanel';
|
||||||
@@ -43,6 +44,7 @@ import { useReady } from './useReady';
|
|||||||
*/
|
*/
|
||||||
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
||||||
home: HomePanel,
|
home: HomePanel,
|
||||||
|
about: AboutPanel,
|
||||||
/*
|
/*
|
||||||
Three sections, one panel, configured — the same split the site's API already makes between
|
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.
|
`records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries.
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ export const SECTIONS: Section[] = [
|
|||||||
icon: Info,
|
icon: Info,
|
||||||
hint: 'The header photograph and the copy',
|
hint: 'The header photograph and the copy',
|
||||||
path: '/fofos-admin/about',
|
path: '/fofos-admin/about',
|
||||||
built: false,
|
built: true,
|
||||||
requires: 'connection',
|
requires: 'connection',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<About | null>(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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, flashSaved] = useFlash();
|
||||||
|
const [picking, setPicking] = useState<Picking>(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<string, string | null>) => {
|
||||||
|
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 (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveButton = (label: string, onClick: () => void) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={onClick} disabled={busy !== null}>
|
||||||
|
{busy === label ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{saved && busy === null && <span className="text-xs text-success">Saved</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 overflow-auto p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The About page, in the order it is shown: a header, the text about you, the product, and the
|
||||||
|
manifesto.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── HEADER: a photograph and a phrase over it ─────────────────────────────────────────── */}
|
||||||
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Header</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">The photograph at the top, and the line over it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={headerUrl}
|
||||||
|
readOnly
|
||||||
|
placeholder="No photograph chosen"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking('header')}>
|
||||||
|
<FolderOpen className="size-3.5" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{saveButton('header', () =>
|
||||||
|
void send('header', { header_url: orNull(headerUrl), header_phrase: orNull(phrase) }),
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── ABOUT US ─────────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">About us</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Who you are.</p>
|
||||||
|
</div>
|
||||||
|
<RichText value={aboutText} onChange={setAboutText} placeholder="Tell people who you are." />
|
||||||
|
{saveButton('about', () => void send('about', { about_text: orNull(aboutText) }))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── PRODUTO: a video beside its text ─────────────────────────────────────────────────── */}
|
||||||
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Product</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">What you make, and a video of it being made.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={productVideo}
|
||||||
|
readOnly
|
||||||
|
placeholder="No video chosen"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking('product')}>
|
||||||
|
<FolderOpen className="size-3.5" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RichText value={productText} onChange={setProductText} placeholder="What the product is." />
|
||||||
|
{saveButton('product', () =>
|
||||||
|
void send('product', { product_video_url: orNull(productVideo), product_text: orNull(productText) }),
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── MANIFESTO ────────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Manifesto</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">What you stand for.</p>
|
||||||
|
</div>
|
||||||
|
<RichText value={manifesto} onChange={setManifesto} placeholder="The manifesto." />
|
||||||
|
{saveButton('manifesto', () => void send('manifesto', { manifesto_text: orNull(manifesto) }))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
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/.
|
||||||
|
*/}
|
||||||
|
<MediaPicker
|
||||||
|
open={picking !== null}
|
||||||
|
onClose={() => 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user