Files
fofos-admin/web/CarouselPicker.tsx
pastilhasandClaude Opus 5 b7455c0470 contain the pickers and let their lists scroll
The carousel list ran off the bottom of the window with no way to reach the end
of it. There are twenty-odd carousels now — three sections' worth — where there
were six when the dialog was written.

`max-h-[80svh]` on the dialog and `min-h-0 overflow-y-auto` on the list. Both are
needed and the second is the one that is easy to miss: a flex child will not
shrink below its content without `min-h-0`, so the list pushes the dialog past
the screen and the overflow never engages. A max-height alone would have looked
like it did nothing.

`svh` rather than `vh`, because on a phone `vh` is the viewport with the browser
chrome hidden — a dialog sized in it is taller than what you can see.

AddComponentDialog gets the same treatment. Its list is a dozen kinds now and was
the same failure waiting for a shorter screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:25:48 +01:00

136 lines
5.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Images, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { readableError } from './errors';
/**
* Choose WHICH carousel a block shows.
*
* ── A reference, not a copy ──
*
* `carousels` is a named, ordered set built to be shown in more than one place — the old home carousel
* already read one by id. So a carousel block stores that id and owns nothing else: reordering the set
* reorders every block pointing at it, and a photograph swapped there is swapped everywhere. That is
* the whole reason the set has a name instead of being a list of files on a page.
*
* It follows that this picker chooses a SET, and editing what is IN the set happens where the set
* lives — under Carousels. A block that could also rearrange its own pictures would be a second place
* to do the same job, and the two would disagree.
*
* ── No thumbnails, deliberately ──
*
* The plugin has no way to address a rendition: `MediaPicker` hands that job to the platform's file
* browser, and every path stored here names an ORIGINAL that a browser cannot display. Rather than
* build a second, worse image pipeline for a dialog, this shows the name and the count — which is what
* tells two carousels apart, since the name is the thing somebody chose.
*/
export type CarouselSummary = {
id: number;
name: string;
slug: string;
items: unknown[];
};
export const CarouselPicker = ({
open,
onClose,
onPick,
selected,
}: {
open: boolean;
onClose: () => void;
onPick: (carouselId: number) => void;
/** The one this block already shows, so reopening the picker says where it stands. */
selected?: number | null;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [carousels, setCarousels] = useState<CarouselSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Re-read on every open rather than once. A carousel created in the other panel while this session
// has been running would otherwise be missing from a list that looks complete.
useEffect(() => {
if (!open) return;
setCarousels(null);
setError(null);
void (async () => {
try {
const response = await clientRef.current.get<{ data: CarouselSummary[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
}
})();
}, [open]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
{/*
Bounded, and the list scrolls inside it.
`max-h-[80svh]` on the dialog and `min-h-0 overflow-y-auto` on the list. Both are needed: a
flex child will not shrink below its content without `min-h-0`, so the list pushes the dialog
taller than the screen and the overflow never engages — which is what happened here. There are
twenty-odd carousels now and the list ran off the bottom of the window with no way to reach the
end of it.
`svh` rather than `vh` because on a phone `vh` is the viewport with the browser chrome hidden,
so a dialog sized in it is taller than what you can actually see.
*/}
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle>Choose a carousel</DialogTitle>
</DialogHeader>
{error && <p className="text-xs text-destructive">{error}</p>}
{!carousels && !error && (
<p className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading…
</p>
)}
{/* Nothing to choose is a real state and says what to do about it, rather than an empty box.
Creating one belongs under Carousels — see the note above about one place per job. */}
{carousels?.length === 0 && (
<p className="p-4 text-xs text-muted-foreground">
No carousels yet. Make one under Carousels, then come back.
</p>
)}
<div className="-mr-2 flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-2">
{carousels?.map((carousel) => {
const count = Array.isArray(carousel.items) ? carousel.items.length : 0;
const current = carousel.id === selected;
return (
<button
key={carousel.id}
type="button"
onClick={() => onPick(carousel.id)}
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
current ? 'border-primary bg-primary/10' : 'border-border hover:border-primary hover:bg-muted/50'
}`}
>
<Images className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{carousel.name}</span>
<span className="text-[11px] text-muted-foreground">
{count === 1 ? '1 item' : `${count} items`}
</span>
</span>
{current && <span className="text-[10px] tracking-wide text-primary uppercase">showing</span>}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
};