It was a read-only text box holding a path and a Browse button beside it. A path is not what anybody is choosing — somebody picking the header photograph is choosing a PHOTOGRAPH, and reading `ABOUT US/HEADER.jpg` to find out whether you picked the right one is doing the computer's job for it. MediaField is the slot: empty, it is a dashed box at the page's own aspect ratio that you click or drop onto; filled, it is the picture, with Change and Remove appearing over it on hover. The panel also stops changing height as things are chosen, so a page with three slots starts to look like the page rather than like a form. ── The aspect ratio is a prop and has to be ── The header is 21:9, the product video 16:9, a gallery slot square. The component cannot know that and must not guess: the SITE decides what shape a slot is, and a component that guessed would be a second opinion to keep in step with the stylesheet. Both callers name the ratio their partial draws. ── Dropping ── It accepts a drag from Officer's own file browser, which carries paths under `application/x-officer-files` — the same payload that moves files between windows. So dragging a photograph out of Media onto a slot is one gesture and no upload. It does not accept a file from the desktop. That is an upload into originals/, a different operation with its own failure modes, and the Media section already does it properly. ── Also ── `thumbUrl` was copied into GalleriesPanel and RecordsPanel and was about to be copied a third time. It is a module now: three copies of a URL shape is three places to be wrong about the rung when the ladder changes, which it did four times in a day. A file that has gone since it was chosen falls back to the empty state rather than a browser's broken-image glyph, and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
8.7 KiB
TypeScript
206 lines
8.7 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 { 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 };
|
|
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 [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 ?? '');
|
|
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_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_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">
|
|
<div className="flex items-center gap-2">
|
|
<p className="flex-1 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>
|
|
{/* Confirmation lives with the content rather than in the bar, because the bar's own button
|
|
has already gone back to saying "Save" by the time anybody looks up. */}
|
|
{saved && <span className="text-xs text-success">Saved</span>}
|
|
</div>
|
|
|
|
{/* ── 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>
|
|
|
|
{/* 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. */}
|
|
<MediaField value={headerUrl || null} onChange={(path) => setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" />
|
|
|
|
<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">
|
|
<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." />
|
|
</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>
|
|
|
|
{/* 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">
|
|
<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." />
|
|
</section>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
</div>
|
|
);
|
|
};
|