From bdc58fb07c6610181c42b4a981c6a8767ae124ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:18:12 +0200 Subject: [PATCH] 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. --- packages/website/src/plugins/author-link.tsx | 1 + packages/website/src/plugins/browse-page.tsx | 13 +-- .../website/src/plugins/contribute-links.tsx | 23 ++++-- packages/website/src/plugins/hero.tsx | 38 +++++++++ .../website/src/plugins/install-count.tsx | 13 +++ packages/website/src/plugins/plugin-card.tsx | 80 +++++++++---------- packages/website/src/plugins/plugin-tile.tsx | 1 + .../website/src/plugins/thumbnails.test.ts | 28 +++---- .../website/src/routes/plugins/$owner.tsx | 5 +- .../src/routes/plugins/$owner_.$slug.tsx | 11 ++- packages/website/src/routes/plugins/index.tsx | 40 +++++----- 11 files changed, 157 insertions(+), 96 deletions(-) create mode 100644 packages/website/src/plugins/hero.tsx create mode 100644 packages/website/src/plugins/install-count.tsx diff --git a/packages/website/src/plugins/author-link.tsx b/packages/website/src/plugins/author-link.tsx index 769fc9cbf5..3a2be803f3 100644 --- a/packages/website/src/plugins/author-link.tsx +++ b/packages/website/src/plugins/author-link.tsx @@ -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; diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 569eb44ab7..1b91c0a94d 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -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({
{results.map((plugin) => query.sort === "new" ? ( - + + {addedAgo(plugin, now)} + ) : ( - + + + ), )}
diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index 3aeb33a16b..705615770d 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -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 ( + <> + + Submit a plugin + + + Read the docs + + + ); +} + +/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */ export function ContributeSection() { return (
- - Read the docs - - - Submit a plugin - +
); diff --git a/packages/website/src/plugins/hero.tsx b/packages/website/src/plugins/hero.tsx new file mode 100644 index 0000000000..9bd9f5ba31 --- /dev/null +++ b/packages/website/src/plugins/hero.tsx @@ -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 ( +
+

+ Plugins + + {plural(pluginCount, "plugin")} by {plural(authorCount, "author")} + +

+

+ Extend Paseo with plugins that can add new themes, workspace panels and new functionality. + Plugins work on web, desktop and mobile. +

+
+
+ +
+ +
+
+ ); +} diff --git a/packages/website/src/plugins/install-count.tsx b/packages/website/src/plugins/install-count.tsx new file mode 100644 index 0000000000..4867415596 --- /dev/null +++ b/packages/website/src/plugins/install-count.tsx @@ -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 ( + + + {formatInstalls(count)} + installs + + ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index 488b97ac55..b68ae654db 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -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 ( -
- -
-

{plugin.name}

-

Added {added}

-
+
+ +

{plugin.name}

+

+ {plugin.description} +

+

+ + {author.name} + {children !== undefined && ( + <> + · + {children} + + )} +

); } -/** 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 ( -
- -
-
-

{plugin.name}

- - {installs === undefined ? ( - added - ) : ( - <> - - {formatInstalls(installs)} - - )} - -
-

- {plugin.description} -

-
+
+ +

{plugin.name}

+ {children}
+

+ {plugin.description} +

); } @@ -110,7 +104,9 @@ export function PluginRankRow({ {getCategory(plugin.categories[0])?.label}

- {formatInstalls(installs)} + + + ); } diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx index c0fc81d483..5e2dc9c31b 100644 --- a/packages/website/src/plugins/plugin-tile.tsx +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -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; diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts index 8769995ca3..fb9a571b93 100644 --- a/packages/website/src/plugins/thumbnails.test.ts +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -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( diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index 0345fa1355..89c4f185ef 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -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() {
{plugins.map((plugin) => ( - + + + ))}
diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index 9ea61f3248..5bb1c73a1e 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -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() { {installCommand(plugin)}
- + + {author.name} {category && ( @@ -75,7 +78,7 @@ function PluginPage() { {category.label} )} - {formatInstalls(installs)} installs + {pluginVersion(plugin)} Source diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 155796a35c..08c31705de 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -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 = { 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(() => ({ sort: "installs", window }), [window]); + const authorCount = new Set(plugins.map(pluginOwner)).size; return ( -
-

- Plugins - - {plugins.length} - -

- -
+ -
+
{featured.length > 0 && (
Featured -

+

A selection of hand picked plugins

@@ -142,13 +134,15 @@ function PluginsPage() { href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS} > - See all plugins + See all
{featured.map((plugin) => ( - + + + ))}
@@ -164,7 +158,9 @@ function PluginsPage() {
{newest.map((plugin) => ( - + + {addedAgo(plugin, now)} + ))}