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
This commit is contained in:
Mohamed Boudra
2026-10-06 21:49:25 +01:00
committed by GitHub
parent 50cf244e86
commit 3f1a04304d
3 changed files with 173 additions and 29 deletions
+66
View File
@@ -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();
+100 -27
View File
@@ -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<Record<string, number>> = { 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<HTMLDialogElement>(null);
const [viewing, setViewing] = useState<MediaItem | null>(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<number | null>(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<HTMLDialogElement>) => {
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<HTMLDialogElement>) => {
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 (
<>
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{media.map((url, index) => {
const source = pluginOverviewUrl(url);
if (!source) return <p key={url}>{url}</p>;
const kind = pluginMediaKind(source);
const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`;
return <MediaTile key={url} url={source} kind={kind} label={label} onOpen={open} />;
})}
{entries.map((entry) =>
typeof entry === "string" ? (
<p key={entry}>{entry}</p>
) : (
<MediaTile key={entry.url} item={entry} className={tileClass} onOpen={open} />
),
)}
</div>
{/* Focusable so arrow keys still reach it after a click on non-focusable media. */}
<dialog
ref={dialog}
aria-label={viewing?.label}
tabIndex={-1}
aria-label={item?.label}
onClose={handleClose}
onClick={closeOnBackdrop}
className="m-auto overflow-visible bg-transparent p-0 backdrop:bg-black/80"
onKeyDownCapture={stepWithArrowKeys}
className="m-auto overflow-visible bg-transparent p-0 outline-none backdrop:bg-black/80"
>
{viewing?.kind === "image" && (
<img src={viewing.url} alt={viewing.label} className={VIEW_CLASS} />
{item?.kind === "image" && (
<img key={viewing} src={item.url} alt={item.label} className={VIEW_CLASS} />
)}
{item?.kind === "video" && <ViewerVideo key={viewing} url={item.url} label={item.label} />}
{items.length > 1 && (
<>
<button
type="button"
aria-label="Previous"
onClick={showPrevious}
className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`}
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next"
onClick={showNext}
className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`}
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
{viewing?.kind === "video" && <ViewerVideo url={viewing.url} label={viewing.label} />}
<button
type="button"
aria-label="Close"
onClick={close}
className="fixed right-4 top-4 rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"
className={`${VIEWER_BUTTON_CLASS} right-4 top-4`}
>
<X className="h-5 w-5" />
</button>
@@ -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<number | null>(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<HTMLAnchorElement>) => {
// 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 (
<a
@@ -125,7 +198,7 @@ function MediaTile({
{...pluginOverviewPolicy.link}
aria-label={kind === "video" ? label : undefined}
onClick={handleClick}
className={TILE_CLASS}
className={className}
>
{kind === "image" ? (
<img
@@ -134,9 +134,14 @@ function PluginsPage() {
<PluginSectionHeader>
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="mt-1 text-sm text-muted-foreground">A selection of hand picked plugins</p>
<p className="mt-1 text-sm text-muted-foreground">
A selection of hand picked plugins
</p>
</div>
<a href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
<a
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all plugins
<ChevronRight className="h-3.5 w-3.5" />
</a>