From 109f3bbb913c2c16dd4f3a160eaaa5526b73a4b5 Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Sat, 17 Oct 2026 12:35:02 -0700 Subject: [PATCH 1/4] Add filterable /apps browse page and header navigation Discovery was split across tags, categories, ecosystems, lexicons and search pages, none reachable from the header. Replace them with one /apps list with a filter sidebar (tags, shared data, rating, extras, category) whose state lives in the URL. - Header gets Browse / Tags / Shared data links and an inline search box; mobile gets the hamburger menu back. - /apps/all, /search, /apps/$tag, /categories/$categoryId and /ecosystems/$app/all 301 to the equivalent /apps preset; internal links point there directly. - New getBrowseListings server fn returns every non-protocol listing with the fields the facets need; filtering/sorting is client-side. - Remove the server fns and helpers only the old pages used. Co-Authored-By: Claude Opus 5.5 --- src/components/AppTagCard.tsx | 5 +- src/components/BrowseFilterPanel.tsx | 381 ++++++++++ src/components/EcosystemCategoryCard.tsx | 5 +- src/components/SiteFooter.tsx | 7 +- src/components/SiteHeader.tsx | 73 +- .../ecosystem-listing-grid.styles.tsx | 16 - src/i18n/locales/en-XA/common.json | 8 +- src/i18n/locales/en/common.json | 8 +- .../api-directory-listings.functions.ts | 193 ++--- src/lib/app-tag-metadata.ts | 26 - src/lib/browse-listings.test.ts | 201 +++++ src/lib/browse-listings.ts | 607 +++++++++++++++ src/lib/directory-categories.ts | 25 - src/routeTree.gen.ts | 21 + src/routes/_header-layout.apps.$tag.tsx | 403 +--------- src/routes/_header-layout.apps.all.tsx | 388 +--------- src/routes/_header-layout.apps.index.tsx | 677 +++++++++++++++++ src/routes/_header-layout.apps.lexicons.tsx | 4 +- src/routes/_header-layout.apps.tags.tsx | 4 +- .../_header-layout.categories.$categoryId.tsx | 707 +----------------- .../_header-layout.ecosystems.$app.all.tsx | 300 +------- .../_header-layout.ecosystems.$app.index.tsx | 14 +- src/routes/_header-layout.index.tsx | 15 +- ...eader-layout.products.$productId.index.tsx | 10 +- src/routes/_header-layout.search.tsx | 405 +--------- 25 files changed, 2103 insertions(+), 2400 deletions(-) create mode 100644 src/components/BrowseFilterPanel.tsx delete mode 100644 src/components/ecosystem-listing-grid.styles.tsx create mode 100644 src/lib/browse-listings.test.ts create mode 100644 src/lib/browse-listings.ts create mode 100644 src/routes/_header-layout.apps.index.tsx diff --git a/src/components/AppTagCard.tsx b/src/components/AppTagCard.tsx index f93d236..dcb5dfc 100644 --- a/src/components/AppTagCard.tsx +++ b/src/components/AppTagCard.tsx @@ -317,9 +317,8 @@ export function AppTagCard({ tag, isFeatured }: AppTagCardProps) { return ( void; +} + +export function BrowseFilterPanel({ + facets, + filters, + onToggle, +}: BrowseFilterPanelProps) { + const isSelected = (facet: BrowseFacet, value: string) => { + switch (facet) { + case "minRating": { + return filters.minRating === Number(value); + } + case "extras": { + return (filters.extras as Array).includes(value); + } + default: { + return filters[facet].includes(value); + } + } + }; + + return ( + + ( + + {getAppTagEmoji(option.label.toLowerCase())} + + )} + /> + + {facets.data.length > 0 ? ( + + ) : null} + + + {facets.minRating.map((option) => ( + onToggle("minRating", option.id)} + /> + ))} + + + + {facets.extras.map((option) => ( + onToggle("extras", option.id)} + /> + ))} + + + isSelected("categories", value)} + onToggle={(value) => onToggle("categories", value)} + /> + + ); +} + +/** + * Category tree: standalone apps, app ecosystems (largest first, the rest behind "show all"), + * then protocol tooling. Ecosystems list their tool categories underneath. + */ +function CategoryFacetSection({ + options, + selectedCount, + isSelected, + onToggle, +}: { + options: Array; + selectedCount: number; + isSelected: (value: string) => boolean; + onToggle: (value: string) => void; +}) { + const [showAll, setShowAll] = useState(false); + const isOptionActive = (option: BrowseFacetOption) => + isSelected(option.id) || + Boolean(option.children?.some((child) => isSelected(child.id))); + + const ecosystems = options.filter( + (option) => option.iconUrl !== undefined && option.children, + ); + const visibleEcosystems = new Set( + (showAll + ? ecosystems + : ecosystems.filter( + (option, index) => + index < COLLAPSED_ECOSYSTEM_COUNT || isOptionActive(option), + ) + ).map((option) => option.id), + ); + const visible = options.filter( + (option) => + !ecosystems.includes(option) || visibleEcosystems.has(option.id), + ); + + return ( + + {visible.map((option) => ( + + onToggle(option.id)} + icon={ + option.iconUrl === undefined ? undefined : ( + + ) + } + /> + {option.children + ?.filter((child) => child.count > 0 || isSelected(child.id)) + .map((child) => ( + onToggle(child.id)} + style={styles.childOption} + /> + ))} + + ))} + {ecosystems.length > COLLAPSED_ECOSYSTEM_COUNT ? ( +
+ +
+ ) : null} +
+ ); +} + +/** Collapsed by default unless it has a selection; pass `defaultExpanded` to override. */ +function FacetSection({ + title, + selectedCount, + defaultExpanded = selectedCount > 0, + hint, + children, +}: { + title: string; + selectedCount: number; + defaultExpanded?: boolean; + hint?: string; + children: React.ReactNode; +}) { + return ( + + + {selectedCount > 0 ? `${title} (${selectedCount})` : title} + + + + {hint ? {hint} : null} + {children} + + + + ); +} + +/** Long facets (tags, data) get an inline filter box and a "show all" toggle. */ +function SearchableFacetSection({ + title, + hint, + facet, + options, + selectedCount, + defaultExpanded, + isSelected, + onToggle, + renderIcon, +}: { + title: string; + hint?: string; + defaultExpanded?: boolean; + facet: BrowseFacet; + options: Array; + selectedCount: number; + isSelected: (facet: BrowseFacet, value: string) => boolean; + onToggle: (facet: BrowseFacet, value: string) => void; + renderIcon?: (option: BrowseFacetOption) => React.ReactNode; +}) { + const [query, setQuery] = useState(""); + const [showAll, setShowAll] = useState(false); + const normalizedQuery = query.trim().toLowerCase(); + + const available = options.filter( + (option) => option.count > 0 || isSelected(facet, option.id), + ); + const matching = normalizedQuery + ? available.filter((option) => + option.label.toLowerCase().includes(normalizedQuery), + ) + : available; + const selectedFirst = matching.toSorted( + (a, b) => Number(isSelected(facet, b.id)) - Number(isSelected(facet, a.id)), + ); + const visible = + showAll || normalizedQuery + ? selectedFirst + : selectedFirst.slice(0, COLLAPSED_OPTION_COUNT); + + if (available.length === 0) { + return null; + } + + return ( + + {available.length > COLLAPSED_OPTION_COUNT ? ( + + ) : null} + {visible.map((option) => ( + onToggle(facet, option.id)} + icon={renderIcon?.(option)} + /> + ))} + {!normalizedQuery && available.length > COLLAPSED_OPTION_COUNT ? ( +
+ +
+ ) : null} +
+ ); +} + +function FacetOption({ + option, + isSelected, + onToggle, + icon, + style, +}: { + option: BrowseFacetOption; + isSelected: boolean; + onToggle: () => void; + icon?: React.ReactNode; + style?: stylex.StyleXStyles; +}) { + return ( + + + {icon} + {option.label} + {option.count} + + + ); +} diff --git a/src/components/EcosystemCategoryCard.tsx b/src/components/EcosystemCategoryCard.tsx index 6d15652..c44e42d 100644 --- a/src/components/EcosystemCategoryCard.tsx +++ b/src/components/EcosystemCategoryCard.tsx @@ -186,9 +186,8 @@ export function EcosystemCategoryCard({ return (