The Save button is gone, and so is the SaveTarget context that announced it. Every screen here is a preview of the thing being edited, so a change is already visible before it is written — a button then asks somebody to confirm what they can see. useAutosave debounces a burst into one request. It takes the unsaved state as a string rather than a flag, so a failed save is not retried on the next render, and it flushes on unmount so a section change loses nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
256 lines
9.6 KiB
TypeScript
256 lines
9.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Check, 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';
|
|
import { useAutosave } from '../useAutosave';
|
|
|
|
/**
|
|
* The Home page: a hero, an intro, and an ordered gallery.
|
|
*
|
|
* ── It edits DEVELOPMENT, and that is the whole model ──
|
|
*
|
|
* Every request here goes through this plugin to the development server's `/api/admin`. Production is
|
|
* a directory of static files with no API at all, so there is nothing to write to there — an edit
|
|
* lands on dev immediately and becomes public when somebody presses Build. Saying that once, here, is
|
|
* better than a person discovering it when a change does not appear on the live site.
|
|
*
|
|
* ── The server owns the shape, and every write returns it ──
|
|
*
|
|
* `PATCH /home` and every gallery route answer with the WHOLE payload, so this never patches its own
|
|
* state from what it sent. That matters more than it looks: the server clears the hero crop when the
|
|
* hero changes, and a client that kept its own copy would go on showing a framing the server had
|
|
* already discarded.
|
|
*
|
|
* ── Ordering is a list, not a field ──
|
|
*
|
|
* `PUT /gallery/order` takes every id in the order they should be. So moving one item sends all of
|
|
* them, which is what makes it impossible to end up with two things claiming the same position.
|
|
*/
|
|
|
|
type GalleryItem = {
|
|
id: number;
|
|
kind: string;
|
|
url: string;
|
|
crop: string | null;
|
|
fit: string | null;
|
|
sort_order: number;
|
|
alt: string | null;
|
|
};
|
|
|
|
type Home = {
|
|
hero: { url: string | null; crop: string | null; fit: string | null };
|
|
intro_text: string | null;
|
|
/** The OLD per-page list. Still returned by the API, and only rendered while no gallery is chosen. */
|
|
gallery: GalleryItem[];
|
|
/** Which gallery the carousel shows. Null means the old list above still applies. */
|
|
gallery_id: number | null;
|
|
};
|
|
|
|
type Gallery = { id: number; name: string; slug: string; items: unknown[] };
|
|
|
|
export const HomePanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [home, setHome] = useState<Home | null>(null);
|
|
const [heroUrl, setHeroUrl] = useState('');
|
|
const [intro, setIntro] = useState('');
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, flashSaved] = useFlash();
|
|
const [picking, setPicking] = useState(false);
|
|
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
|
|
|
/** Every write answers with the whole payload, so this is the only place state is set. */
|
|
const apply = useCallback((data: Home) => {
|
|
setHome(data);
|
|
setHeroUrl(data.hero.url ?? '');
|
|
setIntro(data.intro_text ?? '');
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await clientRef.current.get<{ data: Home }>('/fofos-admin/home');
|
|
apply(response.data);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
}, [apply]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// The galleries to choose between, read from the section that owns them rather than embedded in the
|
|
// home payload — a copy there would go stale the moment somebody edited one.
|
|
useEffect(() => {
|
|
void clientRef.current
|
|
.get<{ data: Gallery[] }>('/fofos-admin/galleries')
|
|
.then((response) => setGalleries(response.data))
|
|
.catch(() => setGalleries([]));
|
|
}, []);
|
|
|
|
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
|
setBusy(label);
|
|
setError(null);
|
|
try {
|
|
apply((await call()).data);
|
|
flashSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
/*
|
|
One PATCH per card, carrying only that card's field.
|
|
|
|
`take()` on the server treats an absent key as "leave it alone", so sending the whole object from
|
|
either card would have each one saving the other's unsaved edits. Two cards, two independent
|
|
saves, and a half-finished intro cannot ride along with a hero somebody meant to change.
|
|
*/
|
|
/*
|
|
One Save for everything this page holds in a field.
|
|
|
|
The gallery choice is NOT part of it: picking a gallery from the list writes immediately, because a
|
|
<select> that needs a second press to mean anything is a control people believe they have already
|
|
used. The two fields below are typed, and typing is what Save is for.
|
|
*/
|
|
const edited = { intro_text: intro.trim() || null, hero_url: heroUrl.trim() || null };
|
|
const stored = home && { intro_text: home.intro_text, hero_url: home.hero.url };
|
|
const dirty = Boolean(stored) && JSON.stringify(edited) !== JSON.stringify(stored);
|
|
|
|
const save = () => send('save', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', edited));
|
|
|
|
useAutosave(dirty ? 'dirty' : null, busy !== null, save);
|
|
|
|
|
|
|
|
if (!home) {
|
|
return (
|
|
<div className="p-5 text-xs text-muted-foreground">
|
|
{error ?? 'Loading…'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
|
{/*
|
|
Intro first, then Hero.
|
|
|
|
The page reads hero-then-intro, and this does not, deliberately: the intro is the thing that
|
|
gets edited, and the hero is a path that changes once a season. Ordering by what somebody came
|
|
to do beats mirroring the layout of the page they are describing.
|
|
*/}
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium">Intro</h3>
|
|
</div>
|
|
{saved && (
|
|
<span className="flex shrink-0 items-center gap-1 text-xs text-success">
|
|
<Check className="size-3.5" />
|
|
Saved
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* HTML in and HTML out — it is what the site's database already holds and renders. */}
|
|
<RichText value={intro} onChange={setIntro} placeholder="The paragraph under the hero." />
|
|
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium">Hero</h3>
|
|
</div>
|
|
|
|
{/*
|
|
A Browse button, not a field.
|
|
|
|
Nobody knows the path of a file they uploaded from a phone, and a text box invites a typo that
|
|
fails silently as a broken image on the live site. Browsing `originals/` means the only values
|
|
that can be stored are ones that exist.
|
|
*/}
|
|
<div className="flex items-center gap-2">
|
|
<p className="min-w-0 flex-1 truncate rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs">
|
|
{heroUrl || <span className="text-muted-foreground/60">Nothing chosen</span>}
|
|
</p>
|
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking(true)}>
|
|
<FolderOpen className="size-3.5" />
|
|
Browse
|
|
</Button>
|
|
</div>
|
|
|
|
<MediaPicker
|
|
open={picking}
|
|
onClose={() => setPicking(false)}
|
|
onPick={setHeroUrl}
|
|
title="Choose the hero"
|
|
/>
|
|
{/* The server clears the crop when the hero changes, because a rectangle describing a region of
|
|
a different picture would crop the new one somewhere arbitrary. Said here so it is not a
|
|
surprise. */}
|
|
{home.hero.crop && (
|
|
<p className="text-xs text-muted-foreground/70">
|
|
Framed at {home.hero.crop} — changing the hero clears that.
|
|
</p>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium">Gallery</h3>
|
|
|
|
{/*
|
|
A picker, not a list.
|
|
|
|
Managing items here would be a second editor for something that already has one, and the two
|
|
would drift: a caption fixed in one place and not the other. The page chooses; the Galleries
|
|
section arranges.
|
|
*/}
|
|
<select
|
|
value={home.gallery_id ?? ''}
|
|
onChange={(event) =>
|
|
void send('gallery', () =>
|
|
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
|
|
gallery_id: event.target.value ? Number(event.target.value) : null,
|
|
}),
|
|
)
|
|
}
|
|
disabled={busy !== null}
|
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs"
|
|
>
|
|
<option value="">None</option>
|
|
{galleries.map((gallery) => (
|
|
<option key={gallery.id} value={gallery.id}>
|
|
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Only while it is true: the old rows are what renders until a gallery is chosen, and saying so
|
|
beats a carousel that appears to come from nowhere. */}
|
|
{home.gallery_id === null && home.gallery.length > 0 && (
|
|
<p className="text-xs text-muted-foreground/70">
|
|
Showing {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old
|
|
per-page list. Choosing a gallery replaces it.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
</div>
|
|
);
|
|
};
|