From 3f1a04304d53b771cd4b633f40af8fef5aca73b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 21:49:25 +0100 Subject: [PATCH] website: plugin gallery peeks a third tile, viewer steps with arrows (#6261) * website: gallery peeks a third tile, viewer steps with arrows With three or more items the strip sizes tiles at 42% from md up so a third tile is cut at the right edge. The viewer gains Previous and Next buttons and Left/Right arrow keys, wrapping at the ends; moving away from a video unmounts it, which stops playback. * website: format the plugins directory route --- packages/website/e2e/plugins.spec.ts | 66 +++++++++ .../website/src/plugins/media-gallery.tsx | 127 ++++++++++++++---- packages/website/src/routes/plugins/index.tsx | 9 +- 3 files changed, 173 insertions(+), 29 deletions(-) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index da668a6c60..9d2b3af8aa 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -424,6 +424,61 @@ test.describe("registry fixture layout", () => { await expect(viewer).toBeHidden(); }); + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; await page.route(plugin.media[0], (route) => route.abort()); @@ -432,6 +487,7 @@ test.describe("registry fixture layout", () => { const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); await expect(video).toBeVisible(); await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); }); test("shows the plugin tile on cards when its media has no image", async ({ page }) => { @@ -481,6 +537,16 @@ function galleryTiles(links: Element[]) { })); } +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + /** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ function viewerFill(media: HTMLElement) { const box = media.getBoundingClientRect(); diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx index 5fe47c4631..ac25a6f53e 100644 --- a/packages/website/src/plugins/media-gallery.tsx +++ b/packages/website/src/plugins/media-gallery.tsx @@ -1,8 +1,9 @@ import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; -import { Play, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, Play, X } from "lucide-react"; import { type CSSProperties, + type KeyboardEvent, type MouseEvent, type SyntheticEvent, useCallback, @@ -12,11 +13,17 @@ import { } from "react"; const TILE_CLASS = - "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"; + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]"; +// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls. +const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`; +const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`; // The viewer's room: media never exceeds this box, which leaves a margin around it. const VIEW_WIDTH = "90vw"; const VIEW_HEIGHT = "85vh"; const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; +const VIEWER_BUTTON_CLASS = + "fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"; +const ARROW_STEPS: Partial> = { ArrowLeft: -1, ArrowRight: 1 }; interface MediaItem { url: string; @@ -28,46 +35,96 @@ interface MediaItem { /** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ export function MediaGallery({ name, media }: { name: string; media: string[] }) { const dialog = useRef(null); - const [viewing, setViewing] = useState(null); - const open = useCallback((item: MediaItem) => { - setViewing(item); - dialog.current?.showModal(); - }, []); + const entries = useMemo( + () => media.map((url, index) => mediaEntry(name, url, index)), + [name, media], + ); + const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]); + const [viewing, setViewing] = useState(null); + const item = viewing === null ? undefined : items[viewing]; + const open = useCallback( + (opened: MediaItem) => { + setViewing(items.indexOf(opened)); + dialog.current?.showModal(); + }, + [items], + ); const close = useCallback(() => dialog.current?.close(), []); // Unmounting the viewed media stops a playing video. const handleClose = useCallback(() => setViewing(null), []); const closeOnBackdrop = useCallback((event: MouseEvent) => { if (event.target === event.currentTarget) event.currentTarget.close(); }, []); + const step = useCallback( + (by: number) => + setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)), + [items.length], + ); + const showPrevious = useCallback(() => step(-1), [step]); + const showNext = useCallback(() => step(1), [step]); + // Captured so arrow keys move between items even while a focused video would seek. + const stepWithArrowKeys = useCallback( + (event: KeyboardEvent) => { + const by = ARROW_STEPS[event.key]; + if (by === undefined || items.length < 2) return; + event.preventDefault(); + step(by); + }, + [items.length, step], + ); if (media.length === 0) return null; + const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS; return ( <>
- {media.map((url, index) => { - const source = pluginOverviewUrl(url); - if (!source) return

{url}

; - const kind = pluginMediaKind(source); - const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`; - return ; - })} + {entries.map((entry) => + typeof entry === "string" ? ( +

{entry}

+ ) : ( + + ), + )}
+ {/* Focusable so arrow keys still reach it after a click on non-focusable media. */} - {viewing?.kind === "image" && ( - {viewing.label} + {item?.kind === "image" && ( + {item.label} + )} + {item?.kind === "video" && } + {items.length > 1 && ( + <> + + + )} - {viewing?.kind === "video" && } @@ -76,6 +133,18 @@ export function MediaGallery({ name, media }: { name: string; media: string[] }) ); } +/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */ +function mediaEntry(name: string, url: string, index: number): MediaItem | string { + const source = pluginOverviewUrl(url); + if (!source) return url; + const kind = pluginMediaKind(source); + return { + url: source, + kind, + label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`, + }; +} + /** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ function ViewerVideo({ url, label }: { url: string; label: string }) { const [aspectRatio, setAspectRatio] = useState(null); @@ -105,19 +174,23 @@ function ViewerVideo({ url, label }: { url: string; label: string }) { } function MediaTile({ - url, - kind, - label, + item, + className, onOpen, -}: MediaItem & { onOpen: (item: MediaItem) => void }) { +}: { + item: MediaItem; + className: string; + onOpen: (item: MediaItem) => void; +}) { + const { url, kind, label } = item; const handleClick = useCallback( (event: MouseEvent) => { // Modified clicks keep the link's own behavior, such as opening the file in a new tab. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); - onOpen({ url, kind, label }); + onOpen(item); }, - [onOpen, url, kind, label], + [onOpen, item], ); return ( {kind === "image" ? (
Featured -

A selection of hand picked plugins

+

+ A selection of hand picked plugins +

-
+ See all plugins