feat(website): plugins hero, author and description on cards, install counts with an icon

The plugins page opens with the directory copy, plugin and author counts,
Submit a plugin and Read the docs, and search. Featured and What's new cards
show the description and the author with avatar; the trailing slot carries
installs on Featured and the added date on What's new. Every install count
on the site is the same download icon and number.
This commit is contained in:
Mohamed Boudra
2026-10-06 23:18:20 +02:00
parent 3f1a04304d
commit bdc58fb07c
11 changed files with 157 additions and 96 deletions
@@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react";
import { type Author, authorAvatarUrl } from "./registry";
const SIZE_CLASS = {
xs: "h-4 w-4 text-[8px]",
sm: "h-5 w-5 text-[10px]",
lg: "h-14 w-14 text-xl",
} as const;
+7 -6
View File
@@ -3,6 +3,7 @@ import { SiteShell } from "~/components/site-shell";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { InstallCount } from "./install-count";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import { PluginSection } from "./section";
@@ -93,13 +94,13 @@ export function BrowsePage({
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
),
)}
</div>
@@ -21,7 +21,21 @@ export function ContributeLinks() {
);
}
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
</a>
</>
);
}
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
export function ContributeSection() {
return (
<section
@@ -36,12 +50,7 @@ export function ContributeSection() {
with the plugin docs, then submit it to get it listed here.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
Read the docs
</a>
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
Submit a plugin
</a>
<ContributeButtons />
</div>
</section>
);
+38
View File
@@ -0,0 +1,38 @@
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
interface PluginsHeroProps {
pluginCount: number;
authorCount: number;
/** Where the search box sends its term. */
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Extend Paseo with plugins that can add new themes, workspace panels and new functionality.
Plugins work on web, desktop and mobile.
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
<ContributeButtons />
</div>
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
</div>
</header>
);
}
@@ -0,0 +1,13 @@
import { Download } from "lucide-react";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
return (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
</span>
);
}
+38 -42
View File
@@ -1,8 +1,10 @@
import { Download } from "lucide-react";
import type { ReactNode } from "react";
import { AuthorAvatar } from "./author-link";
import { InstallCount } from "./install-count";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
@@ -32,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
);
}
/** What's new card: screenshot, name, and when it was added. */
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
/**
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
<AuthorAvatar author={author} size="xs" />
<span className="truncate">{author.name}</span>
{children !== undefined && (
<>
<span aria-hidden>·</span>
<span className="flex-shrink-0">{children}</span>
</>
)}
</p>
</a>
);
}
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
export function PluginCard({
plugin,
installs,
added,
}: {
plugin: Plugin;
/** Shown with a download icon; otherwise `added` is shown. */
installs?: number;
added?: string;
}) {
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-start gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
{installs === undefined ? (
added
) : (
<>
<Download className="h-3 w-3" />
{formatInstalls(installs)}
</>
)}
</span>
</div>
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
@@ -110,7 +104,9 @@ export function PluginRankRow({
{getCategory(plugin.categories[0])?.label}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
<span className="text-xs text-muted-foreground">
<InstallCount count={installs} />
</span>
</a>
);
}
@@ -2,6 +2,7 @@ import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry";
const SIZE_CLASS = {
xs: "h-6 w-6 rounded-md text-xs",
sm: "h-9 w-9 rounded-lg text-sm",
lg: "h-14 w-14 rounded-xl text-xl",
} as const;
+14 -14
View File
@@ -56,20 +56,20 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
it.each([PluginCard, NewPluginCard])(
"renders a thumbnail instead of downloading the original screenshot (%s)",
(Card) => {
const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" }));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
},
);
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
});
function thumbnailRequest(
@@ -16,6 +16,7 @@ import {
} from "~/plugins";
import { AuthorAvatar } from "~/plugins/author-link";
import { PluginsNotFound } from "~/plugins/not-found";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard } from "~/plugins/plugin-card";
import "~/styles.css";
@@ -82,7 +83,9 @@ function AuthorPage() {
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
{plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
))}
</div>
</SiteShell>
@@ -6,7 +6,6 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
firstMediaImage,
formatInstalls,
getAuthor,
getCategory,
getRegistry,
@@ -16,7 +15,8 @@ import {
pluginVersion,
readmeBody,
} from "~/plugins";
import { AuthorLink } from "~/plugins/author-link";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
import { categoryHref } from "~/plugins/links";
import { MediaGallery } from "~/plugins/media-gallery";
import { PluginsNotFound } from "~/plugins/not-found";
@@ -48,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function PluginPage() {
const { plugin, installs } = Route.useLoaderData();
@@ -67,7 +69,8 @@ function PluginPage() {
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={META_LINK_CLASS}>
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
<AuthorAvatar author={author} size="xs" />
{author.name}
</AuthorLink>
{category && (
@@ -75,7 +78,7 @@ function PluginPage() {
{category.label}
</a>
)}
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
<InstallCount count={installs} />
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
+18 -22
View File
@@ -26,8 +26,10 @@ import {
type InstallWindow,
mostInstalled,
newestFirst,
pluginOwner,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
import {
type BrowseQuery,
browseHref,
@@ -37,10 +39,9 @@ import {
parseSearchTerm,
parseSort,
parseWindow,
SUBMIT_URL,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -60,7 +61,6 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
@@ -110,31 +110,23 @@ function PluginsPage() {
[],
);
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
const authorCount = new Set(plugins.map(pluginOwner)).size;
return (
<SiteShell width="default">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-medium tracking-tight">
Plugins
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{plugins.length}
</span>
</h1>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
Submit a plugin
</a>
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
</div>
</div>
<PluginsHero
pluginCount={plugins.length}
authorCount={authorCount}
searchScope={searchScope}
/>
<div className="mt-10 flex flex-col gap-14">
<div className="mt-16 flex flex-col gap-14">
{featured.length > 0 && (
<PluginSection labelledBy="featured">
<PluginSectionHeader>
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="mt-1 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
A selection of hand picked plugins
</p>
</div>
@@ -142,13 +134,15 @@ function PluginsPage() {
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all plugins
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
))}
</div>
</PluginSection>
@@ -164,7 +158,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
))}
</div>
</PluginSection>