{/* 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({
-
+
{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) => (
-
+
+
+
))}
-
- {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}
-
-
- Explore
-
-
- );
-}
-
-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)}
-
-
-
-
-
- )}
-
- );
-}