From f1879e1c5cde12d6ace4305d13253fdd1829b699 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:44:45 -0500 Subject: [PATCH 1/7] feat(ui): add fit variant to CardBanner When fit="contain", auto-generates blurred background from single child image. Co-Authored-By: Claude Opus 4.5 --- src/components/ui/card.stories.tsx | 65 ++++++++++++++++++++++++++++++ src/components/ui/card.tsx | 61 +++++++++++++++++++++++----- 2 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 src/components/ui/card.stories.tsx diff --git a/src/components/ui/card.stories.tsx b/src/components/ui/card.stories.tsx new file mode 100644 index 00000000000..53459633bba --- /dev/null +++ b/src/components/ui/card.stories.tsx @@ -0,0 +1,65 @@ +import Image from "next/image" +import { Meta } from "@storybook/react" + +import { VStack } from "@/components/ui/flex" + +import { CardBanner } from "./card" + +const meta = { + title: "Atoms / Display Content / CardBanner", + component: CardBanner, +} satisfies Meta + +export default meta + +// Default (cover) - image fills, may crop +export const FitCover = { + render: () => ( + +

+ Default fit="cover" - image fills container, may be cropped +

+ + + +
+ ), +} + +// Contain - image fully visible, blur background auto-generated +export const FitContain = { + render: () => ( + +

+ fit="contain" - blur background auto-generated from single + image +

+ + + +
+ ), +} + +// Background variants +export const BackgroundVariants = { + render: () => ( + + {( + ["accent-a", "accent-b", "accent-c", "primary", "body", "none"] as const + ).map((bg) => ( +
+

background: {bg}

+ + + +
+ ))} +
+ ), +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index bc4ab5e77fa..11b2f24056e 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -52,7 +52,7 @@ CardHeader.displayName = "CardHeader" const cardBannerVariants = cva( cn( "overflow-hidden rounded-2xl", - "[&_img]:size-full [&_img]:object-cover [&_img]:duration-200", + "[&_img]:size-full [&_img]:duration-200", "group-hover/link:[&_img]:scale-110 group-hover/link:[&_img]:duration-200 group-focus/link:[&_img]:scale-110 group-focus/link:[&_img]:duration-200" ), { @@ -73,24 +73,63 @@ const cardBannerVariants = cva( full: "h-48 w-full self-stretch", thumbnail: "size-16 shrink-0", }, + fit: { + cover: "[&_img]:object-cover", + contain: "relative [&_img]:object-contain", + }, }, defaultVariants: { background: "body", size: "full", + fit: "cover", }, } ) -const CardBanner = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, background, size, ...props }, ref) => ( -
-)) +type CardBannerProps = React.HTMLAttributes & + VariantProps + +const CardBanner = React.forwardRef( + ({ className, background, size, fit, children, ...props }, ref) => { + // When fit="contain", auto-generate blurred background from single child image + const renderContent = () => { + if (fit === "contain" && React.Children.count(children) === 1) { + const child = React.Children.only(children) + if (React.isValidElement<{ className?: string }>(child)) { + // Blurred background + const blurredBg = React.cloneElement(child, { + className: cn( + child.props.className, + "absolute inset-0 -z-10 scale-110 !object-cover blur-xl" + ), + "aria-hidden": true, + } as React.HTMLAttributes) + // Sharp foreground + const sharpFg = React.cloneElement(child, { + className: cn(child.props.className, "!object-contain"), + }) + return ( + <> + {blurredBg} + {sharpFg} + + ) + } + } + return children + } + + return ( +
+ {renderContent()} +
+ ) + } +) CardBanner.displayName = "CardBanner" const titleVariants = cva( From f3562e0310946a9e45466b0e2b92f41c8bc71838 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:45:22 -0500 Subject: [PATCH 2/7] feat: add reusable AppCard component Co-Authored-By: Claude Opus 4.5 --- src/components/AppCard/AppCard.stories.tsx | 383 +++++++++++++++++++++ src/components/AppCard/index.tsx | 203 +++++++++++ 2 files changed, 586 insertions(+) create mode 100644 src/components/AppCard/AppCard.stories.tsx create mode 100644 src/components/AppCard/index.tsx diff --git a/src/components/AppCard/AppCard.stories.tsx b/src/components/AppCard/AppCard.stories.tsx new file mode 100644 index 00000000000..e8b44c9bdd8 --- /dev/null +++ b/src/components/AppCard/AppCard.stories.tsx @@ -0,0 +1,383 @@ +import { AppWindowMac } from "lucide-react" +import Image from "next/image" +import { Meta, StoryObj } from "@storybook/react" + +import { CardBanner } from "@/components/ui/card" +import { VStack } from "@/components/ui/flex" +import { LinkBox, LinkOverlay } from "@/components/ui/link-box" +import TruncatedText from "@/components/ui/TruncatedText" + +import AppCard from "." + +const meta = { + title: "Molecules / Display Content / AppCard", + component: AppCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +// Sample data using real images +const sampleApp = { + name: "Uniswap", + description: + "Uniswap is a decentralized exchange protocol that allows users to swap tokens without intermediaries. It uses an automated market maker model.", + thumbnail: "/images/dapps/uni.png", + category: "DeFi", + categoryTagStatus: "tag" as const, + tags: ["Exchange", "AMM", "Trading"], +} + +// Layout variants +export const LayoutVertical: Story = { + args: { + ...sampleApp, + layout: "vertical", + href: "/apps/uniswap", + }, +} + +export const LayoutHorizontal: Story = { + args: { + ...sampleApp, + layout: "horizontal", + href: "/apps/uniswap", + }, +} + +export const LayoutComparison = { + render: () => ( + +
+

Vertical (default)

+ +
+
+

Horizontal

+ +
+
+ ), +} + +// Image sizes +export const ImageSizes = { + render: () => ( + + {(["xs", "small", "thumbnail", "medium", "large"] as const).map( + (size) => ( +
+

Size: {size}

+ +
+ ) + )} +
+ ), +} + +// With/without description +export const WithDescription: Story = { + args: { + ...sampleApp, // includes description + layout: "vertical", + href: "/apps/uniswap", + }, +} + +export const WithoutDescription: Story = { + args: { + name: sampleApp.name, + thumbnail: sampleApp.thumbnail, + category: sampleApp.category, + categoryTagStatus: sampleApp.categoryTagStatus, + tags: sampleApp.tags, + // No description prop = no description shown + layout: "vertical", + href: "/apps/uniswap", + }, +} + +// With/without category tag +export const WithCategoryTag: Story = { + args: { + ...sampleApp, // includes category + layout: "vertical", + href: "/apps/uniswap", + }, +} + +export const WithoutCategoryTag: Story = { + args: { + name: sampleApp.name, + description: sampleApp.description, + thumbnail: sampleApp.thumbnail, + // No category prop = no tag shown + tags: sampleApp.tags, + layout: "horizontal", + href: "/apps/uniswap", + }, +} + +// Fallback icon (when no thumbnail) +export const WithFallbackIcon: Story = { + args: { + name: "Unknown App", + description: "An app without a thumbnail image", + tags: ["Development", "Tools"], + layout: "horizontal", + imageSize: "thumbnail", + fallbackIcon: , + href: "/apps/unknown", + }, +} + +// As link vs static +export const AsLink: Story = { + args: { + ...sampleApp, + layout: "horizontal", + href: "/apps/uniswap", + }, + parameters: { + docs: { + description: { + story: + "With `href` prop - renders as a clickable link with hover effect", + }, + }, + }, +} + +export const AsStatic: Story = { + args: { + ...sampleApp, + layout: "horizontal", + }, + parameters: { + docs: { + description: { + story: + "Without `href` prop - renders as static content, no hover effect", + }, + }, + }, +} + +// Different category tag statuses +export const CategoryTagStatuses = { + render: () => ( + + {( + [ + { status: "tag", category: "DeFi" }, + { status: "success", category: "Collectible" }, + { status: "error", category: "Social" }, + { status: "warning", category: "Gaming" }, + { status: "normal", category: "Bridge" }, + ] as const + ).map(({ status, category }) => ( + + ))} + + ), +} + +// Real-world examples +export const AppsPageStyle = { + render: () => ( + +

+ As used on /apps page (vertical with description) +

+ +
+ ), +} + +export const DeveloperAppsPageStyle = { + render: () => ( + +

+ As used on /developers/apps page (horizontal, no category, no tracking) +

+ + } + href="?appId=foundry" + /> +
+ ), +} + +// Category list style (inside bordered container, no card hover) +export const CategoryListStyle = { + render: () => ( + +

+ Apps listed by category (bordered container, no card-level hover) +

+
+
+

DeFi

+
+
+ {[ + { name: "Aave", tags: ["Lending", "Borrowing"] }, + { name: "Uniswap", tags: ["Exchange", "AMM"] }, + { name: "Compound", tags: ["Lending", "Interest"] }, + ].map((app) => ( +
+ +
+ ))} +
+
+
+ ), +} + +// Highlight card style (banner + description + static AppCard) +export const HighlightCardCover = { + render: () => ( + +

+ Banner with fit="cover" - image fills and may crop +

+ + + {/* Banner image - cover crops to fill */} + + App banner + + {/* Description */} +
+ + Uniswap is a decentralized exchange protocol that allows users to + swap tokens without intermediaries. It uses an automated market + maker model for liquidity provision. + +
+ {/* Static AppCard (no href - parent handles link) */} + +
+
+
+ ), +} + +export const HighlightCardContain = { + render: () => ( + +

+ Banner with fit="contain" - image fully visible with blur + background +

+ + + {/* Banner image - contain shows full image with blur bg */} + + App banner + + {/* Description */} +
+ + Uniswap is a decentralized exchange protocol that allows users to + swap tokens without intermediaries. It uses an automated market + maker model for liquidity provision. + +
+ {/* Static AppCard (no href - parent handles link) */} + +
+
+
+ ), +} diff --git a/src/components/AppCard/index.tsx b/src/components/AppCard/index.tsx new file mode 100644 index 00000000000..b373aece90b --- /dev/null +++ b/src/components/AppCard/index.tsx @@ -0,0 +1,203 @@ +import * as React from "react" +import { cva, VariantProps } from "class-variance-authority" + +import type { MatomoEventOptions } from "@/lib/types" + +import { Image } from "@/components/Image" +import { LinkBox, LinkOverlay } from "@/components/ui/link-box" +import { Tag, TagProps, TagsInlineText } from "@/components/ui/tag" +import TruncatedText from "@/components/ui/TruncatedText" + +import { cn } from "@/lib/utils/cn" + +// Outer wrapper variants (hover behavior) +const appCardVariants = cva("group rounded-xl p-2 text-body", { + variants: { + hover: { + highlight: "hover:bg-background-highlight", + none: "", + }, + }, + defaultVariants: { + hover: "highlight", + }, +}) + +const layoutVariants = cva("flex gap-3", { + variants: { + layout: { + horizontal: "", + vertical: "flex-col", + }, + defaultVariant: { + layout: "vertical", + }, + }, +}) + +// Image size variants +const imageSizeVariants = cva("flex shrink-0 overflow-hidden rounded-xl", { + variants: { + size: { + xs: "size-10", // 40px + small: "size-12", // 48px + thumbnail: "size-14", // 56px + medium: "size-16", // 64px + large: "size-24", // 96px + }, + }, + defaultVariants: { + size: "medium", + }, +}) + +type ImageSize = "xs" | "small" | "thumbnail" | "medium" | "large" + +// Map size to pixel values for Image component +const imageSizePixels: Record = { + xs: 40, + small: 48, + thumbnail: 56, + medium: 64, + large: 96, +} + +export interface AppCardProps + extends Omit, "children">, + VariantProps, + VariantProps, + VariantProps { + // Content + name: string + description?: string + thumbnail?: string + category?: string + categoryTagStatus?: TagProps["status"] + tags?: string[] + + // Link + href?: string + + // Layout options + imageSize?: ImageSize + + // Tracking (optional) + customEventOptions?: MatomoEventOptions + descriptionTracking?: MatomoEventOptions + + // Styling + fallbackIcon?: React.ReactNode +} + +const AppCard = React.forwardRef( + ( + { + // Content + name, + description, + thumbnail, + category, + categoryTagStatus, + tags, + // Link + href, + // Layout + layout, + hover, + imageSize, + // Tracking + customEventOptions, + descriptionTracking, + // Styling + className, + fallbackIcon, + ...props + }, + ref + ) => { + const innerContent = ( +
+ {/* Image or fallback */} + {(thumbnail || fallbackIcon) && ( +
+ {thumbnail ? ( + {name} + ) : ( + fallbackIcon + )} +
+ )} + + {/* Content */} +
+ {/* Category tag - shown if category is provided */} + {category && ( + + {category} + + )} + + {/* Name - hover effect triggers when inside a group (LinkBox) */} +

+ {name} +

+ + {/* Description - shown if description is provided */} + {description && ( + + {description} + + )} + + {/* Tags */} + {tags && tags.length > 0 && ( + + )} +
+
+ ) + + // Static card (no link) - no wrapper padding, just the content + if (!href) { + return ( +
+ {innerContent} +
+ ) + } + + // Linked card - uses hover prop + return ( + + + {innerContent} + + + ) + } +) +AppCard.displayName = "AppCard" + +export default AppCard From 1cfe39f84a49877a8e53a158b3688870ca13fac9 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:42:46 -0500 Subject: [PATCH 3/7] refactor: migrate /apps section to new AppCard component - Migrate all 6 /apps consumer files to new component - Delete old app/[locale]/apps/_components/AppCard.tsx Files migrated: - app/[locale]/apps/page.tsx - app/[locale]/apps/[application]/page.tsx - app/[locale]/apps/_components/AppsTable.tsx - app/[locale]/apps/_components/CommunityPicks.tsx - app/[locale]/apps/_components/AppsHighlight.tsx - app/[locale]/apps/_components/TopApps.tsx Co-Authored-By: Claude Opus 4.5 --- app/[locale]/apps/[application]/page.tsx | 30 +++-- app/[locale]/apps/_components/AppCard.tsx | 114 ------------------ .../apps/_components/AppsHighlight.tsx | 15 ++- app/[locale]/apps/_components/AppsTable.tsx | 20 +-- .../apps/_components/CommunityPicks.tsx | 65 +++++++--- app/[locale]/apps/_components/TopApps.tsx | 51 ++++---- app/[locale]/apps/page.tsx | 32 +++-- 7 files changed, 144 insertions(+), 183 deletions(-) delete mode 100644 app/[locale]/apps/_components/AppCard.tsx diff --git a/app/[locale]/apps/[application]/page.tsx b/app/[locale]/apps/[application]/page.tsx index 066a9bd41de..25307364934 100644 --- a/app/[locale]/apps/[application]/page.tsx +++ b/app/[locale]/apps/[application]/page.tsx @@ -8,6 +8,7 @@ import { import type { ChainName, CommitHistory, Lang, PageParams } from "@/lib/types" +import AppCard from "@/components/AppCard" import ChainImages from "@/components/ChainImages" import { ChevronNext } from "@/components/Chevron" import I18nProvider from "@/components/I18nProvider" @@ -41,8 +42,6 @@ import { import { slugify } from "@/lib/utils/url" import { formatStringList } from "@/lib/utils/wallets" -import AppCard from "../_components/AppCard" - import ScreenshotSwiper from "./_components/ScreenshotSwiper" import AppsAppJsonLD from "./page-jsonld" @@ -366,12 +365,27 @@ const Page = async ({ className="flex-1 lg:w-1/3 lg:flex-none" >
))} diff --git a/app/[locale]/apps/_components/AppCard.tsx b/app/[locale]/apps/_components/AppCard.tsx deleted file mode 100644 index bc499bd1a63..00000000000 --- a/app/[locale]/apps/_components/AppCard.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { AppData } from "@/lib/types" - -import { Image } from "@/components/Image" -import { LinkBox, LinkOverlay } from "@/components/ui/link-box" -import { Tag, TagsInlineText } from "@/components/ui/tag" -import TruncatedText from "@/components/ui/TruncatedText" - -import { APP_TAG_VARIANTS } from "@/lib/utils/apps" -import { cn } from "@/lib/utils/cn" -import { slugify } from "@/lib/utils/url" - -import { SIZE_CLASS_MAPPING } from "@/lib/constants" - -interface AppCardProps { - app: AppData - imageSize: number - isVertical?: boolean - showDescription?: boolean - hideTag?: boolean - disableLink?: boolean - hoverClassName?: string - matomoCategory: string - matomoAction: string -} - -const AppCard = ({ - app, - imageSize, - isVertical = false, - showDescription = false, - hideTag = false, - disableLink = false, - hoverClassName, - matomoCategory, - matomoAction, -}: AppCardProps) => { - const cardContent = ( -
-
- {app.name} -
-
- {!hideTag && ( - - {app.category} - - )} -

- {app.name} -

- {showDescription && ( - - {app.description} - - )} - -
-
- ) - - if (disableLink) { - return cardContent - } - - return ( - - - {cardContent} - - - ) -} - -export default AppCard diff --git a/app/[locale]/apps/_components/AppsHighlight.tsx b/app/[locale]/apps/_components/AppsHighlight.tsx index df2181a400b..40487d68c0a 100644 --- a/app/[locale]/apps/_components/AppsHighlight.tsx +++ b/app/[locale]/apps/_components/AppsHighlight.tsx @@ -1,12 +1,13 @@ import { AppData } from "@/lib/types" +import AppCard from "@/components/AppCard" import { Image } from "@/components/Image" import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import TruncatedText from "@/components/ui/TruncatedText" +import { APP_TAG_VARIANTS } from "@/lib/utils/apps" import { slugify } from "@/lib/utils/url" -import AppCard from "./AppCard" import AppsSwiper from "./AppsSwiper" interface AppsHighlightProps { @@ -49,11 +50,13 @@ const AppsHighlight = ({ apps, matomoCategory }: AppsHighlightProps) => { diff --git a/app/[locale]/apps/_components/AppsTable.tsx b/app/[locale]/apps/_components/AppsTable.tsx index 0746c3b2938..e70b387cfd1 100644 --- a/app/[locale]/apps/_components/AppsTable.tsx +++ b/app/[locale]/apps/_components/AppsTable.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react" import { AppData } from "@/lib/types" +import AppCard from "@/components/AppCard" import { Select, SelectContent, @@ -13,8 +14,7 @@ import { } from "@/components/ui/select" import { trackCustomEvent } from "@/lib/utils/matomo" - -import AppCard from "./AppCard" +import { slugify } from "@/lib/utils/url" import useTranslation from "@/hooks/useTranslation" @@ -95,11 +95,17 @@ const AppsTable = ({ apps }: { apps: AppData[] }) => { {filteredApps.map((app) => (
))} diff --git a/app/[locale]/apps/_components/CommunityPicks.tsx b/app/[locale]/apps/_components/CommunityPicks.tsx index ccb02d4d213..3d3affe50d6 100644 --- a/app/[locale]/apps/_components/CommunityPicks.tsx +++ b/app/[locale]/apps/_components/CommunityPicks.tsx @@ -1,10 +1,13 @@ import { AppData, CommunityPick } from "@/lib/types" +import AppCard from "@/components/AppCard" import Twitter from "@/components/icons/twitter.svg" import { Image } from "@/components/Image" import { ButtonLink } from "@/components/ui/buttons/Button" -import AppCard from "./AppCard" +import { APP_TAG_VARIANTS } from "@/lib/utils/apps" +import { slugify } from "@/lib/utils/url" + import AppsSwiper from "./AppsSwiper" const CommunityPicks = ({ @@ -50,24 +53,48 @@ const CommunityPicks = ({
- {pick.app1Name && getApp(pick.app1Name) && ( - - )} - {pick.app2Name && getApp(pick.app2Name) && ( - - )} + {pick.app1Name && + getApp(pick.app1Name) && + (() => { + const app = getApp(pick.app1Name)! + return ( + + ) + })()} + {pick.app2Name && + getApp(pick.app2Name) && + (() => { + const app = getApp(pick.app2Name)! + return ( + + ) + })()}
)) diff --git a/app/[locale]/apps/_components/TopApps.tsx b/app/[locale]/apps/_components/TopApps.tsx index 9acd01e7267..b3d8f89509d 100644 --- a/app/[locale]/apps/_components/TopApps.tsx +++ b/app/[locale]/apps/_components/TopApps.tsx @@ -4,6 +4,7 @@ import { Folder } from "lucide-react" import { AppCategory, AppData } from "@/lib/types" +import AppCard, { type AppCardProps } from "@/components/AppCard" import { Button } from "@/components/ui/buttons/Button" import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { @@ -19,8 +20,6 @@ import { slugify } from "@/lib/utils/url" import { appsCategories } from "@/data/apps/categories" -import AppCard from "./AppCard" - import { useBreakpointValue } from "@/hooks/useBreakpointValue" import { useIsClient } from "@/hooks/useIsClient" import useTranslation from "@/hooks/useTranslation" @@ -32,36 +31,39 @@ interface TopAppsProps { const TopApps = ({ appsData }: TopAppsProps) => { const { t } = useTranslation("page-apps") const isClient = useIsClient() - const cardStyling = useBreakpointValue({ + const cardStyling = useBreakpointValue<{ + layout: AppCardProps["layout"] + imageSize: AppCardProps["imageSize"] + }>({ base: { - isVertical: true, - imageSize: 12, + layout: "vertical", + imageSize: "small", }, sm: { - isVertical: true, - imageSize: 12, + layout: "vertical", + imageSize: "small", }, md: { - isVertical: true, - imageSize: 12, + layout: "vertical", + imageSize: "small", }, lg: { - isVertical: false, - imageSize: 16, + layout: "horizontal", + imageSize: "medium", }, xl: { - isVertical: false, - imageSize: 16, + layout: "horizontal", + imageSize: "medium", }, "2xl": { - isVertical: false, - imageSize: 16, + layout: "horizontal", + imageSize: "medium", }, }) // Use fallback values during SSR to prevent hydration mismatch - const imageSize = isClient ? cardStyling.imageSize : 12 - const isVertical = isClient ? cardStyling.isVertical : true + const imageSize = isClient ? cardStyling.imageSize : "small" + const layout = isClient ? cardStyling.layout : "vertical" return ( @@ -140,12 +142,17 @@ const TopApps = ({ appsData }: TopAppsProps) => { {appsData[category].slice(0, 5).map((app) => (
))} diff --git a/app/[locale]/apps/page.tsx b/app/[locale]/apps/page.tsx index 3a75854e393..44a07b8f30e 100644 --- a/app/[locale]/apps/page.tsx +++ b/app/[locale]/apps/page.tsx @@ -7,20 +7,25 @@ import { import { CommitHistory, Lang, PageParams } from "@/lib/types" +import AppCard from "@/components/AppCard" import Breadcrumbs from "@/components/Breadcrumbs" import { SimpleHero } from "@/components/Hero" import I18nProvider from "@/components/I18nProvider" import MainArticle from "@/components/MainArticle" import SubpageCard from "@/components/SubpageCard" -import { getDiscoverApps, getHighlightedApps } from "@/lib/utils/apps" +import { + APP_TAG_VARIANTS, + getDiscoverApps, + getHighlightedApps, +} from "@/lib/utils/apps" import { getAppPageContributorInfo } from "@/lib/utils/contributors" import { getMetadata } from "@/lib/utils/metadata" import { getRequiredNamespacesForPage } from "@/lib/utils/translations" +import { slugify } from "@/lib/utils/url" import { appsCategories } from "@/data/apps/categories" -import AppCard from "./_components/AppCard" import AppsHighlight from "./_components/AppsHighlight" import CommunityPicks from "./_components/CommunityPicks" import SuggestAnApp from "./_components/SuggestAnApp" @@ -99,11 +104,24 @@ const Page = async ({ params }: { params: PageParams }) => { {discoverApps.map((app) => ( ))} From a2247fbba0800f06b1a1849ed4a4bd43c5815c90 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:40:16 -0500 Subject: [PATCH 4/7] feat(AppCard): add named groups and default icon - Use group/appcard for isolated hover contexts - Add AppWindowMac as default fallback icon - Auto scroll={false} for query-param links (?...) - Title responds to both parent and own group hover Co-Authored-By: Claude Opus 4.5 --- src/components/AppCard/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/AppCard/index.tsx b/src/components/AppCard/index.tsx index b373aece90b..609de413227 100644 --- a/src/components/AppCard/index.tsx +++ b/src/components/AppCard/index.tsx @@ -1,5 +1,6 @@ import * as React from "react" import { cva, VariantProps } from "class-variance-authority" +import { AppWindowMac } from "lucide-react" import type { MatomoEventOptions } from "@/lib/types" @@ -11,7 +12,7 @@ import TruncatedText from "@/components/ui/TruncatedText" import { cn } from "@/lib/utils/cn" // Outer wrapper variants (hover behavior) -const appCardVariants = cva("group rounded-xl p-2 text-body", { +const appCardVariants = cva("group/appcard rounded-xl p-2 text-body", { variants: { hover: { highlight: "hover:bg-background-highlight", @@ -110,7 +111,7 @@ const AppCard = React.forwardRef( descriptionTracking, // Styling className, - fallbackIcon, + fallbackIcon = , ...props }, ref @@ -129,7 +130,7 @@ const AppCard = React.forwardRef( {name} @@ -149,7 +150,7 @@ const AppCard = React.forwardRef( )} {/* Name - hover effect triggers when inside a group (LinkBox) */} -

+

{name}

@@ -174,7 +175,11 @@ const AppCard = React.forwardRef( // Static card (no link) - no wrapper padding, just the content if (!href) { return ( -
+
{innerContent}
) @@ -189,6 +194,7 @@ const AppCard = React.forwardRef( > From 1c56ac9a0a7b2711cb90c6e81a414ba76eb04ff7 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:40:50 -0500 Subject: [PATCH 5/7] refactor(developers/apps): migrate to AppCard - Replace inline card markup with AppCard component - Simplify HighlightsSection banner with fit="contain" - Use AppCard for preview, grid, and highlight cards - Remove redundant imports (Image, LinkBox, etc.) Co-Authored-By: Claude Opus 4.5 --- .../developers/apps/[category]/page.tsx | 48 ++++---------- .../apps/_components/HighlightsSection.tsx | 62 +++++-------------- app/[locale]/developers/apps/page.tsx | 47 ++++---------- 3 files changed, 40 insertions(+), 117 deletions(-) diff --git a/app/[locale]/developers/apps/[category]/page.tsx b/app/[locale]/developers/apps/[category]/page.tsx index 4dd8f8a2510..5d59dcc123d 100644 --- a/app/[locale]/developers/apps/[category]/page.tsx +++ b/app/[locale]/developers/apps/[category]/page.tsx @@ -1,16 +1,13 @@ -import { AppWindowMac } from "lucide-react" -import Image from "next/image" import { redirect } from "next/navigation" import { getTranslations, setRequestLocale } from "next-intl/server" import type { CommitHistory, Lang, PageParams } from "@/lib/types" +import AppCard from "@/components/AppCard" import { ContentHero } from "@/components/Hero" import MainArticle from "@/components/MainArticle" import SubpageCard from "@/components/SubpageCard" -import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { Section } from "@/components/ui/section" -import { TagsInlineText } from "@/components/ui/tag" import { getAppPageContributorInfo } from "@/lib/utils/contributors" import { getMetadata } from "@/lib/utils/metadata" @@ -146,39 +143,18 @@ const Page = async ({
{categoryData.map((app) => ( - - -
- {app.thumbnail_url ? ( - - ) : ( - - )} -
-
-

{app.name}

- - t(`page-developers-apps-tag-${tag}`) - )} - variant="light" - className="lowercase" - /> -
-
-
+ name={app.name} + thumbnail={app.thumbnail_url} + tags={app.tags.map((tag) => + t(`page-developers-apps-tag-${tag}`) + )} + href={buildAppLink(app.id)} + layout="horizontal" + imageSize="thumbnail" + className="h-fit p-4" + /> ))}
diff --git a/app/[locale]/developers/apps/_components/HighlightsSection.tsx b/app/[locale]/developers/apps/_components/HighlightsSection.tsx index 9b833366211..bd442816a1c 100644 --- a/app/[locale]/developers/apps/_components/HighlightsSection.tsx +++ b/app/[locale]/developers/apps/_components/HighlightsSection.tsx @@ -1,14 +1,14 @@ import Image from "next/image" import { getLocale, getTranslations } from "next-intl/server" -import { CardBanner, CardParagraph, CardTitle } from "@/components/ui/card" +import AppCard from "@/components/AppCard" +import { CardBanner, CardParagraph } from "@/components/ui/card" import { EdgeScrollContainer, EdgeScrollItem, } from "@/components/ui/edge-scroll-container" import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { Section } from "@/components/ui/section" -import { Tag, TagsInlineText } from "@/components/ui/tag" import { cn } from "@/lib/utils/cn" @@ -19,7 +19,6 @@ import { getCategoryTagStyle } from "../utils" const HighlightsSection = async ({ apps }: { apps: DeveloperApp[] }) => { const locale = await getLocale() const t = await getTranslations({ locale, namespace: "page-developers-apps" }) - const tCommon = await getTranslations({ locale, namespace: "common" }) // Don't render section if no apps to highlight if (apps.length === 0) return null @@ -52,61 +51,32 @@ const HighlightsSection = async ({ apps }: { apps: DeveloperApp[] }) => { className="space-y-6 no-underline" >
- + - {app.description}
-
- - {tCommon("item-logo", - - -
- - {t( - `page-developers-apps-category-${categorySlug}-title` - )} - - - {app.name} - - - t(`page-developers-apps-tag-${tag}`) - )} - variant="light" - className="lowercase" - /> -
-
+ + t(`page-developers-apps-tag-${tag}`) + )} + layout="horizontal" + imageSize="medium" + />
diff --git a/app/[locale]/developers/apps/page.tsx b/app/[locale]/developers/apps/page.tsx index 65bd9846f75..ac53bd06ae9 100644 --- a/app/[locale]/developers/apps/page.tsx +++ b/app/[locale]/developers/apps/page.tsx @@ -1,10 +1,9 @@ -import { AppWindowMac } from "lucide-react" -import Image from "next/image" import { redirect } from "next/navigation" import { getTranslations, setRequestLocale } from "next-intl/server" import type { CommitHistory, Lang, PageParams } from "@/lib/types" +import AppCard from "@/components/AppCard" import { ContentHero } from "@/components/Hero" import MainArticle from "@/components/MainArticle" import SubpageCard from "@/components/SubpageCard" @@ -16,7 +15,6 @@ import { } from "@/components/ui/edge-scroll-container" import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { Section } from "@/components/ui/section" -import { TagsInlineText } from "@/components/ui/tag" import { getAppPageContributorInfo } from "@/lib/utils/contributors" import { getMetadata } from "@/lib/utils/metadata" @@ -122,39 +120,18 @@ const Page = async ({ {previewsByCategory[slug].map((app) => ( - - -
- {app.thumbnail_url ? ( - - ) : ( - - )} -
-
-

{app.name}

- - t(`page-developers-apps-tag-${tag}`) - )} - variant="light" - className="lowercase" - /> -
-
-
+ name={app.name} + thumbnail={app.thumbnail_url} + tags={app.tags.map((tag) => + t(`page-developers-apps-tag-${tag}`) + )} + href={`?appId=${app.id}`} + layout="horizontal" + imageSize="thumbnail" + className="rounded-none border-t p-4" + /> ))} From d262850318243c4a54b648618098e16720f69501 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:13:05 -0500 Subject: [PATCH 6/7] patch: named group-hover, fixes hover on category app stack --- app/[locale]/developers/apps/_components/HighlightsSection.tsx | 2 +- src/components/AppCard/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/[locale]/developers/apps/_components/HighlightsSection.tsx b/app/[locale]/developers/apps/_components/HighlightsSection.tsx index bd442816a1c..dd6b157c3d0 100644 --- a/app/[locale]/developers/apps/_components/HighlightsSection.tsx +++ b/app/[locale]/developers/apps/_components/HighlightsSection.tsx @@ -41,7 +41,7 @@ const HighlightsSection = async ({ apps }: { apps: DeveloperApp[] }) => { > diff --git a/src/components/AppCard/index.tsx b/src/components/AppCard/index.tsx index 609de413227..10d92d17475 100644 --- a/src/components/AppCard/index.tsx +++ b/src/components/AppCard/index.tsx @@ -150,7 +150,7 @@ const AppCard = React.forwardRef( )} {/* Name - hover effect triggers when inside a group (LinkBox) */} -

+

{name}

From 9b51e01af7f215ca0f2e077bea37cf16e88586c7 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:05:51 -0500 Subject: [PATCH 7/7] refactor: use AppCard --- .../apps/_components/CategoryAppsGrid.tsx | 44 +++++-------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx b/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx index fe55e81746c..1e612c829ea 100644 --- a/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx +++ b/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx @@ -1,12 +1,9 @@ "use client" import { useMemo, useState } from "react" -import { AppWindowMac } from "lucide-react" -import Image from "next/image" +import AppCard from "@/components/AppCard" import FilterBar from "@/components/FilterBar" -import { LinkBox, LinkOverlay } from "@/components/ui/link-box" -import { TagsInlineText } from "@/components/ui/tag" import type { DeveloperApp, DeveloperAppTag } from "../types" @@ -46,37 +43,16 @@ export default function CategoryAppsGrid({
{filteredApps.map((app) => ( - - -
- {app.thumbnail_url ? ( - - ) : ( - - )} -
-
-

{app.name}

- tagLabels[tag])} - variant="light" - className="lowercase" - /> -
-
-
+ name={app.name} + thumbnail={app.thumbnail_url} + tags={app.tags.map((tag) => tagLabels[tag])} + href={`?appId=${app.id}`} + layout="horizontal" + imageSize="thumbnail" + className="h-fit p-4" + /> ))}