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>
136 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
};
|