diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx new file mode 100644 index 0000000..a3d7883 --- /dev/null +++ b/src/components/AppCard.tsx @@ -0,0 +1,516 @@ +import type { ReactNode } from "react"; + +import * as stylex from "@stylexjs/stylex"; +import { Link as RouterLink } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; +import { useId } from "react"; +import { VisuallyHidden } from "react-aria"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; + +import { Avatar } from "../design-system/avatar"; +import { Badge } from "../design-system/badge"; +import { Card, CardImage } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { StarRating } from "../design-system/star-rating"; +import { + animationDuration, + animationTimingFunction, +} from "../design-system/theme/animations.stylex"; +import { primaryColor, uiColor } from "../design-system/theme/color.stylex"; +import { radius } from "../design-system/theme/radius.stylex"; +import { + gap, + horizontalSpace, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { shadow } from "../design-system/theme/shadow.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getListingLabels } from "../lib/listing-labels"; + +/** + * - large: hero slot. Image on top, full details underneath. + * - medium: promo slot. Same anatomy as large, in a smaller slot. + * - small: details only, no image. + */ +export type AppCardSize = "large" | "medium" | "small"; + +/** + * The card surface is a size container, so a card adapts to the width it is + * given (e.g. a row of three on a tablet) rather than to the viewport. + */ +const NARROW_CARD = "@container (max-width: 16rem)"; + +const styles = stylex.create({ + /** + * The link is the hit area. It never moves, so the pointer can't slip off + * its edge mid-hover (which made the cursor flicker between arrow and hand). + * All motion and the focus ring live on the surface inside it. + */ + link: { + WebkitTapHighlightColor: "transparent", + textDecoration: "none", + color: "inherit", + cursor: "pointer", + display: "block", + outlineStyle: "none", + height: "100%", + // Let grid/flex parents shrink the card; long labels truncate instead. + minWidth: 0, + }, + surface: { + borderRadius: radius.lg, + cornerShape: "squircle", + boxShadow: { + default: shadow.sm, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + boxSizing: "border-box", + containerType: "inline-size", + outlineColor: primaryColor.solid1, + outlineOffset: 2, + outlineStyle: { + default: "none", + [stylex.when.ancestor(":focus-visible")]: "solid", + }, + outlineWidth: 2, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateY(-2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform, box-shadow", + transitionTimingFunction: animationTimingFunction.easeOut, + height: "100%", + }, + image: { + transform: { + default: "scale(1)", + [stylex.when.ancestor(":hover")]: "scale(1.02)", + }, + transitionDuration: animationDuration.verySlow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, + }, + content: { + gap: { + [NARROW_CARD]: gap["3xl"], + default: gap["4xl"], + }, + boxSizing: "border-box", + flexGrow: 1, + paddingBottom: { + [NARROW_CARD]: verticalSpace["3xl"], + default: verticalSpace["4xl"], + }, + paddingLeft: { + [NARROW_CARD]: horizontalSpace["3xl"], + default: horizontalSpace["4xl"], + }, + paddingRight: { + [NARROW_CARD]: horizontalSpace["3xl"], + default: horizontalSpace["4xl"], + }, + paddingTop: { + [NARROW_CARD]: verticalSpace["3xl"], + default: verticalSpace["4xl"], + }, + }, + contentAfterImage: { + paddingTop: 0, + }, + // A large card's image absorbs any spare height (e.g. when the hero sits + // beside a taller column of spotlights) so the card never has an empty band. + // It only ever grows past 16:9, cropping the sides, never shrinks below it. + imageFill: { + flexGrow: 1, + }, + noGrow: { + flexGrow: 0, + }, + // Narrow cards stack the icon above the name so long names keep the full + // card width instead of breaking mid-word beside the icon. + identity: { + gap: gap["2xl"], + alignItems: { + [NARROW_CARD]: "flex-start", + default: "center", + }, + display: "flex", + flexDirection: { + [NARROW_CARD]: "column", + default: "row", + }, + minWidth: 0, + }, + identityText: { + minWidth: 0, + }, + name: { + overflowWrap: "anywhere", + }, + // Spare height goes above the footer, so footers line up across a row. + // (Growing the tagline instead would reveal lines past its clamp.) + footer: { + marginTop: "auto", + }, + footerRow: { + gap: { + [NARROW_CARD]: gap["md"], + default: gap["xl"], + }, + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + taglineClamped: { + overflow: "hidden", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 3, + display: "-webkit-box", + }, + labels: { + gap: gap["sm"], + overflow: "hidden", + alignItems: "center", + display: "flex", + minWidth: 0, + }, + labelsWrapped: { + overflow: "visible", + flexWrap: "wrap", + rowGap: gap["sm"], + }, + labelWrapped: { + maxWidth: "100%", + minWidth: 0, + }, + // Only the first label may shrink; later ones keep their natural width up + // to a cap, so a short tag like "Work" never collapses to "W…". + label: { + flexShrink: 0, + maxWidth: "10rem", + minWidth: 0, + }, + firstLabel: { + flexShrink: 1, + maxWidth: "none", + }, + wideOnly: { + display: { + [NARROW_CARD]: "none", + default: "flex", + }, + }, + narrowOnly: { + display: { + [NARROW_CARD]: "flex", + default: "none", + }, + }, + wideOnlyInline: { + display: { + [NARROW_CARD]: "none", + default: "inline", + }, + }, + narrowOnlyInline: { + display: { + [NARROW_CARD]: "inline", + default: "none", + }, + }, + labelEmoji: { + marginRight: "0.3em", + }, + labelText: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: 0, + }, + moreLabel: { + flexShrink: 0, + }, + rating: { + alignItems: "center", + columnGap: { + [NARROW_CARD]: gap["sm"], + default: gap["md"], + }, + display: "flex", + flexWrap: "wrap", + rowGap: gap["xs"], + minWidth: 0, + }, + chevron: { + color: uiColor.text1, + flexShrink: 0, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateX(2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, + }, +}); + +function getHandle(listing: DirectoryListingCard) { + return listing.productAccountHandle?.replace(/^@/, "") || "unknown"; +} + +/** + * The whole card is a single link. Keep everything inside it non-interactive + * (no buttons, no nested links) so there is exactly one click target. + */ +function AppCardLink({ + listing, + labelledBy, + describedBy, + children, +}: { + listing: DirectoryListingCard; + labelledBy?: string; + describedBy?: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function AppCard({ + listing, + size, +}: { + listing: DirectoryListingCard; + size: AppCardSize; +}) { + const nameId = useId(); + const taglineId = useId(); + const labelsId = useId(); + const ratingId = useId(); + + const isSmall = size === "small"; + const imageUrl = isSmall ? null : listing.heroImageUrl; + const fillWithImage = size === "large" && Boolean(imageUrl); + const hasLabels = getListingLabels(listing).length > 0; + + return ( + + + {imageUrl ? ( + + ) : null} + +
+ + + + {listing.name} + + @{getHandle(listing)} + +
+ {isSmall ? ( + + {listing.tagline} + + ) : ( + + {listing.tagline} + + )} + + +
+ + +
+
+
+
+
+ ); +} + +/** + * Category + tag chips, capped so they always fit on one line with the rest + * summarised as "+N": two chips normally, one in a narrow card. Chips are + * plain labels, never links, so the card stays a single click target. + * + * Screen readers get the full list as text; the chips are visual only. + */ +export function AppCardLabels({ + id, + listing, + showAll = false, +}: { + id?: string; + listing: Pick; + /** Show every label, wrapping onto more lines (list rows have the room). */ + showAll?: boolean; +}) { + const labels = getListingLabels(listing); + if (labels.length === 0) return null; + + const visible = showAll ? labels : labels.slice(0, 2); + const moreWhenWide = labels.length - visible.length; + const moreWhenNarrow = showAll ? 0 : labels.length - 1; + + return ( +
+ + {labels.map((label) => label.label).join(", ")} + + {visible.map((label, index) => ( + + + {label.emoji ? ( + {label.emoji} + ) : null} + {label.label} + + + ))} + {moreWhenWide > 0 ? ( + + +{moreWhenWide} + + ) : null} + {moreWhenNarrow > 0 ? ( + + +{moreWhenNarrow} + + ) : null} +
+ ); +} + +/** + * Stars plus the score and review count. Unrated apps show empty stars and + * "No reviews yet" so every card keeps the same footer shape. Narrow cards + * drop the numeric score (the stars already show it) and shorten the copy. + * + * Screen readers get one plain sentence; the visual parts are hidden. + */ +export function AppCardRating({ + id, + rating, + reviewCount, +}: { + id?: string; + rating: number | null; + reviewCount: number; +}) { + const hasReviews = rating != null && reviewCount > 0; + + return ( +
+ + {hasReviews + ? `Rated ${rating.toFixed(1)} out of 5, ${String(reviewCount)} ${reviewCount === 1 ? "review" : "reviews"}` + : "No reviews yet"} + + + + + + {hasReviews ? ( + <> + + {rating.toFixed(1)}{" "} + + ({reviewCount}) + + ) : ( + <> + No reviews yet + No reviews + + )} + +
+ ); +} diff --git a/src/components/AppListRow.tsx b/src/components/AppListRow.tsx new file mode 100644 index 0000000..4e300f7 --- /dev/null +++ b/src/components/AppListRow.tsx @@ -0,0 +1,362 @@ +import type { ReactNode } from "react"; + +import * as stylex from "@stylexjs/stylex"; +import { Link as RouterLink } from "@tanstack/react-router"; +import { ChevronRight, Heart } from "lucide-react"; +import { useId } from "react"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; +import type { BrowseListingCard } from "../lib/browse-listings"; + +import { Avatar } from "../design-system/avatar"; +import { Card } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { + animationDuration, + animationTimingFunction, +} from "../design-system/theme/animations.stylex"; +import { primaryColor, uiColor } from "../design-system/theme/color.stylex"; +import { breakpoints } from "../design-system/theme/media-queries.stylex"; +import { radius } from "../design-system/theme/radius.stylex"; +import { + gap, + horizontalSpace, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { shadow } from "../design-system/theme/shadow.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getListingLabels } from "../lib/listing-labels"; +import { AppCardLabels, AppCardRating } from "./AppCard"; + +/** + * List rows for apps. Two variants share one look and one interaction: + * - RankedAppRow: compact, numbered (home "Trending"). + * - DetailedAppRow: labels, rating, favorites, last update (Browse). + * + * Like AppCard, the whole row is a single link that never moves; the surface + * inside it tints on hover and draws the keyboard focus ring. + */ + +const styles = stylex.create({ + link: { + WebkitTapHighlightColor: "transparent", + textDecoration: "none", + color: "inherit", + cursor: "pointer", + display: "block", + outlineStyle: "none", + minWidth: 0, + }, + // Rows sit flush against each other. This padding is the visual gap between + // rows but still belongs to the link, so the pointer never falls into a dead + // zone (and flips back to an arrow) while moving down the list. + rankedLink: { + paddingBottom: verticalSpace.xs, + paddingTop: verticalSpace.xs, + }, + detailedLink: { + paddingBottom: verticalSpace.sm, + paddingTop: verticalSpace.sm, + }, + surface: { + borderRadius: radius.md, + cornerShape: "squircle", + backgroundColor: { + default: uiColor.bg, + [stylex.when.ancestor(":hover")]: uiColor.component1, + }, + boxShadow: { + default: shadow.none, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + outlineColor: primaryColor.solid1, + outlineOffset: 2, + outlineStyle: { + default: "none", + [stylex.when.ancestor(":focus-visible")]: "solid", + }, + outlineWidth: 2, + transitionDuration: animationDuration.slow, + transitionProperty: "background-color, box-shadow", + transitionTimingFunction: animationTimingFunction.easeOut, + }, + detailedSurface: { + borderRadius: radius.lg, + containIntrinsicSize: "auto 7rem", + boxShadow: { + default: shadow.sm, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + contentVisibility: "auto", + }, + rankedBody: { + gap: gap.xl, + alignItems: "center", + display: "flex", + paddingBottom: verticalSpace["2xl"], + paddingLeft: horizontalSpace["4xl"], + paddingRight: horizontalSpace["2xl"], + paddingTop: verticalSpace["2xl"], + }, + rank: { + minWidth: "1.25rem", + }, + rankedText: { + flexBasis: "0%", + flexGrow: 1, + flexShrink: 1, + minWidth: 0, + }, + detailedBody: { + gridTemplateAreas: { + default: '"avatar main chevron" "avatar stats chevron"', + [breakpoints.md]: '"avatar main stats chevron"', + }, + alignItems: "start", + columnGap: gap["3xl"], + display: "grid", + gridTemplateColumns: { + default: "auto minmax(0, 1fr) auto", + [breakpoints.md]: "auto minmax(0, 1fr) auto auto", + }, + rowGap: gap.md, + paddingBottom: verticalSpace["3xl"], + paddingLeft: horizontalSpace["3xl"], + paddingRight: horizontalSpace["2xl"], + paddingTop: verticalSpace["3xl"], + }, + avatarArea: { + gridColumnEnd: "avatar", + gridColumnStart: "avatar", + gridRowEnd: "avatar", + gridRowStart: "avatar", + }, + mainArea: { + gridColumnEnd: "main", + gridColumnStart: "main", + gridRowEnd: "main", + gridRowStart: "main", + minWidth: 0, + }, + nameLine: { + flexWrap: "wrap", + rowGap: gap.sm, + }, + statsArea: { + alignItems: { + default: "center", + [breakpoints.md]: "flex-end", + }, + columnGap: gap["2xl"], + display: "flex", + flexDirection: { + default: "row", + [breakpoints.md]: "column", + }, + flexWrap: "wrap", + gridColumnEnd: "stats", + gridColumnStart: "stats", + gridRowEnd: "stats", + gridRowStart: "stats", + rowGap: gap.sm, + whiteSpace: "nowrap", + }, + statLine: { + gap: gap.xs, + alignItems: "center", + color: uiColor.text1, + display: "inline-flex", + }, + chevronArea: { + alignSelf: "center", + gridColumnEnd: "chevron", + gridColumnStart: "chevron", + gridRowEnd: "chevron", + gridRowStart: "chevron", + }, + chevron: { + color: uiColor.text1, + display: "block", + flexShrink: 0, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateX(2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, + }, +}); + +type RowListing = { + name: string; + slug?: string | null; +}; + +function AppRowLink({ + listing, + labelledBy, + describedBy, + style, + children, +}: { + listing: RowListing; + labelledBy: string; + describedBy: string; + style: stylex.StyleXStyles; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function RowChevron() { + return ; +} + +/** + * Compact numbered row. Render inside an `
    `: the list supplies the + * position to screen readers, so the visible number is decorative. + */ +export function RankedAppRow({ + listing, + rank, +}: { + listing: DirectoryListingCard; + rank: number; +}) { + const nameId = useId(); + const taglineId = useId(); + + return ( + +
    + + {rank} + + + + + {listing.name} + + + {listing.tagline} + + + +
    +
    + ); +} + +const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { + numeric: "auto", +}); + +function formatUpdatedAgo(iso: string) { + const days = Math.round((Date.parse(iso) - Date.now()) / 86_400_000); + if (days > -30) return relativeTimeFormat.format(days, "day"); + if (days > -365) { + return relativeTimeFormat.format(Math.round(days / 30), "month"); + } + return relativeTimeFormat.format(Math.round(days / 365), "year"); +} + +/** Full-detail row for Browse: every label, rating, favorites, last update. */ +export function DetailedAppRow({ listing }: { listing: BrowseListingCard }) { + const nameId = useId(); + const taglineId = useId(); + const labelsId = useId(); + const ratingId = useId(); + const handle = listing.productAccountHandle?.replace(/^@/, ""); + + return ( + 0 ? labelsId : null, + ratingId, + ] + .filter(Boolean) + .join(" ")} + style={styles.detailedLink} + > + +
    +
    + +
    + + + + + {listing.name} + + {handle ? ( + @{handle} + ) : null} + + + {listing.tagline} + + + + +
    + + + + {`${listing.favoriteCount} ${listing.favoriteCount === 1 ? "favorite" : "favorites"}`} + + + Updated {formatUpdatedAgo(listing.updatedAt)} + +
    + +
    + +
    +
    +
    +
    + ); +} diff --git a/src/components/EcosystemListingCard.tsx b/src/components/EcosystemListingCard.tsx deleted file mode 100644 index b585503..0000000 --- a/src/components/EcosystemListingCard.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import * as stylex from "@stylexjs/stylex"; -import { Link as RouterLink } from "@tanstack/react-router"; - -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; -import { Flex } from "../design-system/flex"; -import { StarRating } from "../design-system/star-rating"; -import { - gap, - horizontalSpace, - verticalSpace, -} from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; -import { FeaturedListingFallbackCard } from "./FeaturedListingFallbackCard"; -import { HeroImage } from "./HeroImage"; - -const styles = stylex.create({ - listingLink: { - textDecoration: "none", - display: "block", - height: "100%", - }, - listingCard: { - contentVisibility: "auto", - height: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - height: "100%", - paddingBottom: verticalSpace["4xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["4xl"], - paddingTop: verticalSpace["4xl"], - }, - listingHeader: { - gap: gap["2xl"], - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, - listingTagline: { - flexGrow: 1, - }, - listingFooter: { - alignItems: "center", - }, - ratingRow: { - flexWrap: "wrap", - }, -}); - -export function EcosystemListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured && listing.heroImageUrl ? ( - - ) : featured ? ( - - ) : ( - - - - - - - {listing.name} - - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || - "unknown"} - - - - - - - {listing.tagline} - - -
    - - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - - )} - - ); -} diff --git a/src/components/FeaturedListingFallbackCard.tsx b/src/components/FeaturedListingFallbackCard.tsx deleted file mode 100644 index 06dd9dd..0000000 --- a/src/components/FeaturedListingFallbackCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import * as stylex from "@stylexjs/stylex"; - -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; -import { Flex } from "../design-system/flex"; -import { uiColor } from "../design-system/theme/color.stylex"; -import { radius } from "../design-system/theme/radius.stylex"; -import { - gap, - horizontalSpace, - verticalSpace, -} from "../design-system/theme/semantic-spacing.stylex"; -import { Body } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; -import { getInitials } from "../lib/get-initials"; - -const styles = stylex.create({ - card: { - borderRadius: radius["3xl"], - cornerShape: "squircle", - backgroundColor: uiColor.component1, - height: "100%", - }, - body: { - gap: gap["2xl"], - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - height: "100%", - paddingBottom: verticalSpace["6xl"], - paddingLeft: horizontalSpace["6xl"], - paddingRight: horizontalSpace["6xl"], - paddingTop: verticalSpace["6xl"], - }, - description: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minHeight: 0, - }, - title: { - overflow: "hidden", - WebkitBoxOrient: "vertical", - WebkitLineClamp: 2, - display: "-webkit-box", - textOverflow: "ellipsis", - }, -}); - -/** - * Rendered inside a featured grid slot when the listing has no `heroImageUrl`. - * Keeps the oversized 16:9 tile from rendering empty by surfacing the - * listing's identity (name + tagline) instead. - */ -export function FeaturedListingFallbackCard({ - listing, -}: { - listing: Pick< - DirectoryListingCard, - "name" | "tagline" | "description" | "iconUrl" - >; -}) { - return ( - - - - - {listing.name} - - - {listing.tagline || listing.description} - - - - ); -} diff --git a/src/components/FeaturedListingGrid.tsx b/src/components/FeaturedListingGrid.tsx index 19b7778..e6b1a87 100644 --- a/src/components/FeaturedListingGrid.tsx +++ b/src/components/FeaturedListingGrid.tsx @@ -6,6 +6,7 @@ import * as stylex from "@stylexjs/stylex"; import { Grid } from "../design-system/grid"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap } from "../design-system/theme/semantic-spacing.stylex"; +import { FEATURED_LISTING_BLOCK_SIZE } from "../lib/featured-listing-grid"; const styles = stylex.create({ grid: { @@ -21,16 +22,19 @@ const styles = stylex.create({ item: { minWidth: 0, }, + // Same rhythm as the home hero: on desktop the large card takes two columns + // and three rows, with three small cards stacked beside it; on tablet it + // spans the full width. Its image grows to fill whatever height that is. featuredItem: { gridColumn: { default: "auto", - [breakpoints.sm]: "span 2", + [breakpoints.sm]: "1 / -1", + [breakpoints.xl]: "span 2", }, gridRow: { default: "auto", - [breakpoints.sm]: "span 2", + [breakpoints.xl]: "span 3", }, - aspectRatio: 16 / 9, }, }); @@ -59,7 +63,8 @@ export function FeaturedListingGrid({ items, hasFeatured = true, getKey, - isFeatured = (_, index) => index % 9 === 0, + // One large card + three beside it + a row of three = seven per block. + isFeatured = (_, index) => index % FEATURED_LISTING_BLOCK_SIZE === 0, canFeature, renderItem, style, diff --git a/src/design-system/aspect-ratio/index.tsx b/src/design-system/aspect-ratio/index.tsx index e47d843..2b05a1b 100644 --- a/src/design-system/aspect-ratio/index.tsx +++ b/src/design-system/aspect-ratio/index.tsx @@ -66,7 +66,7 @@ export function AspectRatioImage({ ...props }: AspectRatioImageProps) { return ( -
    +
    {/* eslint-disable-next-line jsx-a11y/alt-text */} {children} diff --git a/src/design-system/pagination/index.tsx b/src/design-system/pagination/index.tsx index 0de4d80..9c6b304 100644 --- a/src/design-system/pagination/index.tsx +++ b/src/design-system/pagination/index.tsx @@ -19,11 +19,19 @@ import { gap } from "../theme/semantic-spacing.stylex"; const styles = stylex.create({ list: { + margin: 0, + padding: 0, gap: gap["2xl"], alignItems: "center", containerType: "inline-size", display: "flex", }, + // Without this the page numbers keep the browser's default list indent, + // which pushed the control past the edge of narrow phone screens. + pageList: { + margin: 0, + padding: 0, + }, pages: { flexGrow: 1, }, @@ -118,7 +126,7 @@ export function Pagination({
  1. -
      +
        {visiblePages[0] !== 1 && (
      • diff --git a/src/integrations/tanstack-query/api-admin.functions.ts b/src/integrations/tanstack-query/api-admin.functions.ts index 5d37ca6..736f50d 100644 --- a/src/integrations/tanstack-query/api-admin.functions.ts +++ b/src/integrations/tanstack-query/api-admin.functions.ts @@ -33,7 +33,7 @@ import { z } from "zod"; import { dbMiddleware } from "./db-middleware"; -const HOME_HERO_SLOT_COUNT = 3; +const HOME_HERO_SLOT_COUNT = 4; const RECENT_REVIEWS_LIMIT = 200; const RECENT_LISTINGS_LIMIT = 200; const ADMIN_OVERVIEW_REVIEWS_PREVIEW = 6; diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index 72e5bef..c51aec0 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -1346,6 +1346,9 @@ function dedupeListings(rows: Array) { }); } +/** Small cards shown beside (desktop) or under (tablet) the home hero. */ +const HOME_SPOTLIGHT_COUNT = 3; + function requireCards( cards: Array, desiredCount: number, @@ -1358,16 +1361,22 @@ function requireCards( return cards.slice(0, desiredCount); } +/** + * Admin-picked hero rows first, in order, then recent apps to fill any empty + * slots. A partial admin list is kept rather than discarded, so adding a slot + * never throws away an existing configuration. + */ function resolveConfiguredHomeHeroRows(input: { configuredRows: Array; fallbackRows: Array; desiredCount: number; }) { - if (input.configuredRows.length >= input.desiredCount) { - return input.configuredRows.slice(0, input.desiredCount); - } + const configuredIds = new Set(input.configuredRows.map((row) => row.id)); - return input.fallbackRows.slice(0, input.desiredCount); + return [ + ...input.configuredRows, + ...input.fallbackRows.filter((row) => !configuredIds.has(row.id)), + ].slice(0, input.desiredCount); } function buildCategories(rows: Array, limit = 4) { @@ -2063,7 +2072,7 @@ const getHomePageData = createServerFn({ method: "GET" }) const heroRows = resolveConfiguredHomeHeroRows({ configuredRows: dedupeListings(configuredHomeHeroRows), fallbackRows: dedupedRecentAppRows, - desiredCount: 3, + desiredCount: 1 + HOME_SPOTLIGHT_COUNT, }); const featuredSource = heroRows[0] ?? fallbackFeaturedSource; @@ -2077,10 +2086,10 @@ const getHomePageData = createServerFn({ method: "GET" }) ? heroRows.slice(1) : dedupedRecentAppRows.filter((row) => row.id !== featuredSource.id); - const spotlightRows = remainingAppRows.slice(0, 2); + const spotlightRows = remainingAppRows.slice(0, HOME_SPOTLIGHT_COUNT); const spotlights = requireCards( spotlightRows.map((row) => toListingCard(row)), - 2, + HOME_SPOTLIGHT_COUNT, "homepage spotlights", ); diff --git a/src/lib/featured-listing-grid.ts b/src/lib/featured-listing-grid.ts new file mode 100644 index 0000000..17c8f00 --- /dev/null +++ b/src/lib/featured-listing-grid.ts @@ -0,0 +1,5 @@ +/** + * Apps per block in a FeaturedListingGrid: one large card, three small cards + * stacked beside it, then a row of three. + */ +export const FEATURED_LISTING_BLOCK_SIZE = 7; diff --git a/src/lib/listing-labels.test.ts b/src/lib/listing-labels.test.ts new file mode 100644 index 0000000..3bc95a2 --- /dev/null +++ b/src/lib/listing-labels.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { getListingLabels } from "./listing-labels"; + +describe("getListingLabels", () => { + it("puts the ecosystem category before editorial tags", () => { + const labels = getListingLabels({ + categorySlugs: ["apps/bluesky/client"], + appTags: ["social", "video"], + }); + + expect(labels.map((label) => label.kind)).toEqual([ + "category", + "tag", + "tag", + ]); + expect(labels[0]?.label).toBe("Bluesky › Client"); + expect(labels[1]?.label).toBe("Social"); + expect(labels[1]?.emoji).toBeTruthy(); + }); + + it("skips a top-level app's own apps/ category", () => { + const labels = getListingLabels({ + categorySlugs: ["apps/smoke-signal"], + appTags: ["events"], + }); + + expect(labels.map((label) => label.kind)).toEqual(["tag"]); + }); + + it("returns nothing when there is no category or tag", () => { + expect( + getListingLabels({ categorySlugs: ["apps/solo"], appTags: [] }), + ).toEqual([]); + }); +}); diff --git a/src/lib/listing-labels.ts b/src/lib/listing-labels.ts new file mode 100644 index 0000000..5e10db3 --- /dev/null +++ b/src/lib/listing-labels.ts @@ -0,0 +1,44 @@ +import { formatAppTagLabel } from "./app-tag-metadata"; +import { getAppTagEmoji } from "./app-tag-visuals"; +import { getBrowseCategoryLabel } from "./browse-listings"; + +export interface ListingLabel { + key: string; + kind: "category" | "tag"; + label: string; + emoji?: string; +} + +/** + * Display labels for an app, in the order browse shows them: the ecosystem + * category first, then editorial app tags. + * + * Top-level apps sit in their own `apps/` category, which adds nothing + * as a label, so only ecosystem tools and protocol categories are included. + */ +export function getListingLabels(listing: { + categorySlugs: Array; + appTags: Array; +}): Array { + const category = listing.categorySlugs.find( + (slug) => !(slug.startsWith("apps/") && slug.split("/").length === 2), + ); + + return [ + ...(category + ? [ + { + key: `category:${category}`, + kind: "category" as const, + label: getBrowseCategoryLabel(category), + }, + ] + : []), + ...listing.appTags.map((tag) => ({ + key: `tag:${tag}`, + kind: "tag" as const, + label: formatAppTagLabel(tag), + emoji: getAppTagEmoji(tag), + })), + ]; +} diff --git a/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx b/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx index 80e08cb..c844af1 100644 --- a/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx +++ b/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx @@ -27,11 +27,12 @@ export const Route = createFileRoute( component: HomePageHeroPage, }); -const HOME_HERO_SLOT_COUNT = 3; +const HOME_HERO_SLOT_COUNT = 4; const HOME_HERO_SLOT_LABELS = [ "Featured app", "Spotlight app 1", "Spotlight app 2", + "Spotlight app 3", ] as const; const styles = stylex.create({ @@ -160,7 +161,9 @@ function HomePageHeroPage() { return; } if (new Set(listingIds).size !== listingIds.length) { - setHeroError("Please select three different apps."); + setHeroError( + "Please select a different app for each slot.", + ); return; } diff --git a/src/routes/_header-layout.apps.index.tsx b/src/routes/_header-layout.apps.index.tsx index 9a2fdab..afff258 100644 --- a/src/routes/_header-layout.apps.index.tsx +++ b/src/routes/_header-layout.apps.index.tsx @@ -1,27 +1,20 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { - Link as RouterLink, - createFileRoute, - createLink, -} from "@tanstack/react-router"; -import { Heart, SearchX, SlidersHorizontal, X } from "lucide-react"; +import { createFileRoute } from "@tanstack/react-router"; +import { SearchX, SlidersHorizontal, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { BrowseFacet, BrowseFacets, BrowseFilters, - BrowseListingCard, BrowseSearch, BrowseSort, } from "../lib/browse-listings"; +import { DetailedAppRow } from "../components/AppListRow"; import { BrowseFilterPanel } from "../components/BrowseFilterPanel"; -import { Avatar } from "../design-system/avatar"; -import { Badge } from "../design-system/badge"; import { Button } from "../design-system/button"; -import { Card } from "../design-system/card"; import { Drawer, DrawerBody, @@ -40,41 +33,32 @@ import { Page } from "../design-system/page"; import { Pagination } from "../design-system/pagination"; import { SearchField } from "../design-system/search-field"; import { Select, SelectItem } from "../design-system/select"; -import { StarRating } from "../design-system/star-rating"; import { uiColor } from "../design-system/theme/color.stylex"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; import { Body, Heading1, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; import { formatAppTagLabel, getAppTagDescription, - getAppTagSlug, } from "../lib/app-tag-metadata"; -import { getAppTagEmoji } from "../lib/app-tag-visuals"; import { BROWSE_SORTS, BROWSE_SORT_LABELS, TOP_LEVEL_APPS_CATEGORY, clearBrowseFilters, countActiveBrowseFilters, - getBrowseCategoryLabel, getBrowseFilterValueLabel, getBrowseFilters, toggleBrowseFilter, validateBrowseSearch, } from "../lib/browse-listings"; import { getDirectoryCategoryOption } from "../lib/directory-categories"; -import { getInitials } from "../lib/get-initials"; import { buildAppTagOgImageUrl, buildRouteOgMeta } from "../lib/og-meta"; -const BadgeLink = createLink(Badge); - const QUERY_DEBOUNCE_MS = 250; const sortOptions = BROWSE_SORTS.map((id) => ({ @@ -211,65 +195,15 @@ const styles = stylex.create({ resultCount: { color: uiColor.text1, }, - row: { - contentVisibility: "auto", - }, - rowBody: { - alignItems: "flex-start", - columnGap: gap["3xl"], - display: "grid", - gridTemplateColumns: { - default: "auto minmax(0, 1fr)", - [breakpoints.md]: "auto minmax(0, 1fr) auto", - }, - rowGap: gap["md"], - paddingBottom: verticalSpace["3xl"], - paddingLeft: horizontalSpace["3xl"], - paddingRight: horizontalSpace["3xl"], - paddingTop: verticalSpace["3xl"], - }, - rowLink: { - textDecoration: { - default: "none", - ":hover": "underline", - }, - color: "inherit", - }, - rowMeta: { - flexWrap: "wrap", - rowGap: gap["sm"], - }, - badgeLink: { - textDecoration: "none", - }, - rowStats: { - alignItems: { - default: "center", - [breakpoints.md]: "flex-end", - }, - columnGap: gap["2xl"], - display: "flex", - flexDirection: { - default: "row", - [breakpoints.md]: "column", - }, - flexWrap: "wrap", - gridColumnEnd: { - default: "span 2", - [breakpoints.md]: "auto", - }, - gridColumnStart: { - default: "1", - [breakpoints.md]: "auto", - }, - rowGap: gap["sm"], - whiteSpace: "nowrap", - }, - statLine: { - gap: gap["xs"], - alignItems: "center", - color: uiColor.text1, - display: "inline-flex", + // Rows carry the spacing between them (see DetailedAppRow); pull the list + // out by that amount so the first and last rows line up as before. + results: { + padding: 0, + listStyle: "none", + marginBottom: `calc(${verticalSpace.sm} * -1)`, + marginLeft: 0, + marginRight: 0, + marginTop: `calc(${verticalSpace.sm} * -1)`, }, emptyState: { padding: verticalSpace["10xl"], @@ -391,11 +325,13 @@ function BrowseAppsPage() { {results.length > 0 ? ( - +
          {results.map((listing) => ( - +
        • + +
        • ))} - +
        ) : ( @@ -536,114 +472,3 @@ function ActiveFilterChips({
        ); } - -const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { - numeric: "auto", -}); - -function formatUpdatedAgo(iso: string) { - const days = Math.round((Date.parse(iso) - Date.now()) / 86_400_000); - if (days > -30) return relativeTimeFormat.format(days, "day"); - if (days > -365) { - return relativeTimeFormat.format(Math.round(days / 30), "month"); - } - return relativeTimeFormat.format(Math.round(days / 365), "year"); -} - -function addFilter(search: BrowseSearch, facet: BrowseFacet, value: string) { - const filters = getBrowseFilters(search); - const selected = - facet === "categories" - ? filters.categories - : facet === "tags" - ? filters.tags - : []; - return selected.includes(value) - ? search - : toggleBrowseFilter(search, facet, value); -} - -function BrowseListingRow({ listing }: { listing: BrowseListingCard }) { - const productParams = { productId: listing.slug }; - const handle = listing.productAccountHandle?.replace(/^@/, ""); - // Top-level apps are their own `apps/` category, so only badge ecosystem tools and protocol. - const category = listing.categorySlugs.find( - (slug) => !(slug.startsWith("apps/") && slug.split("/").length === 2), - ); - - return ( - -
        - - - - - - - - - {listing.name} - - - {handle ? ( - @{handle} - ) : null} - - {listing.tagline} - - {category ? ( - - addFilter(prev, "categories", category) - } - size="sm" - style={styles.badgeLink} - > - {getBrowseCategoryLabel(category)} - - ) : null} - {listing.appTags.map((tag) => ( - - addFilter(prev, "tags", getAppTagSlug(tag)) - } - size="sm" - variant="primary" - style={styles.badgeLink} - > - {getAppTagEmoji(tag)} {formatAppTagLabel(tag)} - - ))} - - - -
        - - - - {`${listing.favoriteCount} ${listing.favoriteCount === 1 ? "favorite" : "favorites"}`} - - - Updated {formatUpdatedAgo(listing.updatedAt)} - -
        -
        -
        - ); -} diff --git a/src/routes/_header-layout.apps.lexicon-set.tsx b/src/routes/_header-layout.apps.lexicon-set.tsx index ed9feac..38045a0 100644 --- a/src/routes/_header-layout.apps.lexicon-set.tsx +++ b/src/routes/_header-layout.apps.lexicon-set.tsx @@ -8,18 +8,12 @@ import { redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; import { Badge } from "../design-system/badge"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -27,16 +21,11 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, size, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatLexiconClusterPageTitle, formatOAuthLexiconKeyClusterStyleHeadline, @@ -60,9 +49,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -77,41 +63,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -325,7 +276,7 @@ function AppsLexiconSetPage() { getKey={(listing) => `${gridKey}-${listing.id}`} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -341,69 +292,3 @@ function AppsLexiconSetPage() { ); } - -function LexiconListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -} diff --git a/src/routes/_header-layout.apps.lexicon.tsx b/src/routes/_header-layout.apps.lexicon.tsx index f3dd6ee..a3eafd4 100644 --- a/src/routes/_header-layout.apps.lexicon.tsx +++ b/src/routes/_header-layout.apps.lexicon.tsx @@ -1,24 +1,17 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; import { - Link as RouterLink, createFileRoute, createLink, notFound, redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -26,15 +19,10 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatOAuthLexiconKeyClusterStyleHeadline, oauthLexiconKeyKindLabel, @@ -57,9 +45,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -74,41 +59,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -248,7 +198,7 @@ function AppsLexiconPage() { getKey={(listing) => `${key}-${listing.id}`} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -264,69 +214,3 @@ function AppsLexiconPage() { ); } - -function LexiconListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -} diff --git a/src/routes/_header-layout.ecosystems.$app.index.tsx b/src/routes/_header-layout.ecosystems.$app.index.tsx index 89b79d9..ffb7f73 100644 --- a/src/routes/_header-layout.ecosystems.$app.index.tsx +++ b/src/routes/_header-layout.ecosystems.$app.index.tsx @@ -5,8 +5,8 @@ import { ChevronLeft } from "lucide-react"; import type { DirectoryCategoryTreeNode } from "../lib/directory-categories"; +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { EcosystemListingCard } from "../components/EcosystemListingCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; import { Button } from "../design-system/button"; import { Flex } from "../design-system/flex"; @@ -29,6 +29,7 @@ import { getListingsForCategoryBranch, pickListingImageForCategoryBranch, } from "../lib/ecosystem-listings"; +import { FEATURED_LISTING_BLOCK_SIZE } from "../lib/featured-listing-grid"; import { buildRouteOgMeta } from "../lib/og-meta"; const ButtonLink = createLink(Button); @@ -145,12 +146,20 @@ function EcosystemIndexPage() { const categorySections = category.children .map((child) => ({ category: child, - listings: getListingsForCategoryBranch(child.id, listings).slice( + listings: getListingsForCategoryBranch(child.id, listings), + })) + .filter((section) => section.listings.length > 0) + .map((section, index) => ({ + ...section, + // The first section leads with a large card (one large + three beside + // it + a row of three); later sections are two plain rows of three. + listings: section.listings.slice( 0, - INITIAL_SECTION_LISTING_COUNT, + index === 0 + ? FEATURED_LISTING_BLOCK_SIZE + : INITIAL_SECTION_LISTING_COUNT, ), - })) - .filter((section) => section.listings.length > 0); + })); return ( @@ -247,7 +256,7 @@ function EcosystemCategorySection({ canFeature={(listing) => Boolean(listing.heroImageUrl)} style={styles.sectionGrid} renderItem={(listing, { featured }) => ( - + )} /> diff --git a/src/routes/_header-layout.index.tsx b/src/routes/_header-layout.index.tsx index dbb0a9c..53cb943 100644 --- a/src/routes/_header-layout.index.tsx +++ b/src/routes/_header-layout.index.tsx @@ -1,7 +1,6 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; import { - Link as RouterLink, createFileRoute, createLink, useNavigate, @@ -10,21 +9,15 @@ import { ChevronRight } from "lucide-react"; import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; +import { AppCard } from "../components/AppCard"; +import { RankedAppRow } from "../components/AppListRow"; import { AppTagCard } from "../components/AppTagCard"; -import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; import { Alert } from "../design-system/alert"; -import { Avatar } from "../design-system/avatar"; import { Button } from "../design-system/button"; -import { Card, CardImage } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Grid } from "../design-system/grid"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; -import { - animationDuration, - animationTimingFunction, -} from "../design-system/theme/animations.stylex"; import { uiColor } from "../design-system/theme/color.stylex"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { radius } from "../design-system/theme/radius.stylex"; @@ -33,18 +26,9 @@ import { horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { shadow } from "../design-system/theme/shadow.stylex"; -import { - Body, - Heading1, - Heading2, - SmallBody, -} from "../design-system/typography"; +import { Heading1, Heading2, SmallBody } from "../design-system/typography"; import { Text } from "../design-system/typography/text"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { buildRouteOgMeta } from "../lib/og-meta"; export const Route = createFileRoute("/_header-layout/")({ @@ -76,48 +60,6 @@ const styles = stylex.create({ headerDescription: { maxWidth: "41rem", }, - bentoLink: { - textDecoration: "none", - color: uiColor.text2, - display: "block", - position: "relative", - zIndex: 1, - }, - bentoLinkFeatured: { - aspectRatio: "16 / 9", - zIndex: 0, - }, - listingCardLink: { - borderRadius: radius.lg, - cornerShape: "squircle", - textDecoration: "none", - boxShadow: shadow.md, - display: "block", - transform: { - default: "none", - ":hover": "translateY(-2px)", - }, - transitionDuration: animationDuration.slow, - transitionProperty: "transform", - transitionTimingFunction: "ease-in-out", - height: "100%", - - ":hover::before": { - opacity: 1, - }, - "::before": { - inset: 0, - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.slow, - transitionProperty: "opacity", - transitionTimingFunction: "ease-in-out", - }, - }, claimBanner: { width: "100%", }, @@ -143,72 +85,10 @@ const styles = stylex.create({ flexDirection: "column", width: "100%", }, - promoCard: { - borderColor: uiColor.component2, - borderRadius: radius["lg"], - borderStyle: "solid", - borderWidth: 1, - boxShadow: shadow.none, - color: uiColor.text2, - transform: { - default: "none", - ":hover": "translateY(-2px)", - }, - transitionDuration: animationDuration.slow, - transitionProperty: "transform", - transitionTimingFunction: "ease-in-out", - height: "100%", - }, - promoCardShadow: { - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.md, - position: "relative", - - ":hover::before": { - opacity: 1, - }, - "::before": { - inset: 0, - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.default, - transitionProperty: "opacity", - transitionTimingFunction: animationTimingFunction.linear, - }, - }, - promoCardBody: { - boxSizing: "border-box", - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - justifyContent: "flex-start", - position: "relative", - zIndex: 1, - paddingBottom: verticalSpace["3xl"], - paddingLeft: horizontalSpace["3xl"], - paddingRight: horizontalSpace["3xl"], - paddingTop: verticalSpace["sm"], - }, - promoCardTagline: { - flexGrow: 1, - }, eyebrow: { letterSpacing: "0.16em", textTransform: "uppercase", }, - heroTitle: { - display: "block", - maxWidth: "18ch", - }, - heroDescription: { - margin: 0, - maxWidth: "32rem", - }, sectionHeader: { marginBottom: verticalSpace["3xl"], }, @@ -240,105 +120,59 @@ const styles = stylex.create({ [breakpoints.lg]: "1fr", }, }, + // Rows carry their own spacing (see RankedAppRow), so the vertical padding + // here is trimmed by that amount to keep the panel's inset unchanged. popularList: { - padding: verticalSpace["2xl"], + margin: 0, borderColor: uiColor.component2, borderRadius: radius.xl, borderStyle: "solid", borderWidth: 1, cornerShape: "squircle", + listStyle: "none", backgroundColor: uiColor.bg, + paddingBottom: verticalSpace.lg, + paddingLeft: horizontalSpace["2xl"], + paddingRight: horizontalSpace["2xl"], + paddingTop: verticalSpace.lg, }, - listItem: { - borderRadius: radius.md, - gap: gap["xl"], - textDecoration: "none", - alignItems: "center", - backgroundColor: { - default: uiColor.bg, - ":hover": uiColor.component2, - }, - boxShadow: shadow.none, - color: uiColor.text2, - display: "flex", - position: "relative", - transitionDuration: animationDuration.slow, - transitionProperty: "background-color, z-index", - transitionTimingFunction: "ease-in-out", - zIndex: { - default: 0, - ":hover": 1, + // Phone: one column. Tablet: hero full width, spotlights as a row of three. + // Desktop: hero takes two columns, spotlights stack in the third. + heroGrid: { + gap: gap["2xl"], + display: "grid", + gridTemplateColumns: { + default: "minmax(0, 1fr)", + [breakpoints.sm]: "repeat(3, minmax(0, 1fr))", }, - paddingBottom: verticalSpace["2xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["2xl"], - paddingTop: verticalSpace["2xl"], - - ":hover::after": { - opacity: 1, + }, + heroItem: { + gridColumn: { + default: "auto", + [breakpoints.sm]: "1 / -1", + [breakpoints.xl]: "1 / span 2", }, - "::after": { - inset: 0, - borderRadius: radius.md, - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.slow, - transitionProperty: "opacity", - transitionTimingFunction: "ease-in-out", + gridRow: { + default: "auto", + [breakpoints.xl]: "1 / span 3", }, + minWidth: 0, }, - rankNumber: { - minWidth: "1.25rem", - }, - listItemText: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", + spotlightItem: { + gridColumn: { + default: "auto", + [breakpoints.xl]: "3", + }, minWidth: 0, }, newGrid: { gap: gap["lg"], display: "grid", gridTemplateColumns: { - default: "1fr", - [breakpoints.sm]: "repeat(2, minmax(0, 1fr))", - [breakpoints.lg]: "repeat(3, minmax(0, 1fr))", + default: "minmax(0, 1fr)", + [breakpoints.sm]: "repeat(3, minmax(0, 1fr))", }, }, - listingCard: { - boxShadow: shadow.none, - boxSizing: "border-box", - position: "relative", - height: "100%", - }, - listingCardContent: { - boxSizing: "border-box", - display: "flex", - flexDirection: "column", - height: "100%", - paddingBottom: verticalSpace["4xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["4xl"], - paddingTop: verticalSpace["4xl"], - }, - listItemTagline: { - flexGrow: 1, - minWidth: 0, - }, - exploreButton: { - borderRadius: radius.full, - cornerShape: "unset", - cursor: "pointer", - }, - heroImagePlaceholder: { - flexGrow: 1, - minHeight: 0, - }, - heroCardContent: { - height: "100%", - }, }); function HomePage() { @@ -350,6 +184,10 @@ function HomePage() { directoryListingApi.getProductClaimEligibilityQueryOptions(), ); const promoListing = data.promo; + const [hero, ...spotlights] = putHeroImageFirst([ + data.featured, + ...data.spotlights, + ]); const showClaimBanner = claimEligibility.eligible && claimEligibility.listings.length > 0; @@ -401,21 +239,16 @@ function HomePage() {
        - - index === 0 ? `featured-${listing.id}` : `spotlight-${listing.id}` - } - isFeatured={(_, index) => index === 0} - canFeature={(listing) => Boolean(listing.heroImageUrl)} - renderItem={(listing, { featured }) => - featured ? ( - - ) : ( - - ) - } - /> +
        +
        + +
        + {spotlights.map((listing) => ( +
        + +
        + ))} +
        @@ -443,16 +276,16 @@ function HomePage() { promoListing ? null : styles.popularGridSingle, ]} > - +
          {data.popular.map((listing, index) => ( - +
        1. + +
        2. ))} - - {promoListing ? : null} +
        + {promoListing ? ( + + ) : null}
        @@ -465,7 +298,7 @@ function HomePage() { /> {data.fresh.map((listing) => ( - + ))} @@ -527,180 +360,18 @@ function SectionHeader({ eyebrow, title, to, search }: SectionHeaderProps) { ); } -function HeroCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - {listing.heroImageUrl ? ( -
        - -
        - ) : ( - - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || - "unknown"} - - - - - {listing.tagline} - - - - - )} -
        - ); -} - -function PopularListItem({ - listing, - rank, -}: { - listing: DirectoryListingCard; - rank: number; -}) { - return ( - - - {rank} - - - - - {listing.name} - - {listing.tagline} - - - - ); -} - -function PromoCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - - {listing.heroImageUrl ? ( - - ) : null} - - - - - - {listing.name} - - - @{listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - - - - - ); -} +/** + * The hero slot is the big image card, so make sure it gets a listing that + * has an image: swap the first spotlight with one if the featured app has + * none. If no listing has an image the hero renders without one. + */ +function putHeroImageFirst(listings: Array) { + if (listings[0]?.heroImageUrl) return listings; -function ListingCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - - - - - - - {listing.name} - - - @{listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - - - - - ); -} + const withImage = listings.findIndex((listing) => listing.heroImageUrl); + if (withImage <= 0) return listings; -function StoreIcon({ - listing, - size, -}: { - listing: DirectoryListingCard; - size: "lg" | "xl"; -}) { - return ( - - ); + const arranged = [...listings]; + [arranged[0], arranged[withImage]] = [arranged[withImage], arranged[0]]; + return arranged; } diff --git a/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx index f1f81ba..5625da7 100644 --- a/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx +++ b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx @@ -8,18 +8,12 @@ import { redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; import { Badge } from "../design-system/badge"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -27,19 +21,15 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, size, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; import { getDirectoryListingSlug, getLegacyDirectoryListingId, } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatOAuthLexiconKeyClusterStyleHeadline, oauthLexiconKeyKindLabel, @@ -64,9 +54,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -81,41 +68,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -329,7 +281,7 @@ function LexiconCompatibleAppsPage() { getKey={(listing) => listing.id} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -374,69 +326,3 @@ function LexiconMatchBadge({ ); } - -function CompatibleAppListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -}