mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The commits section read numbers that were typed into scripts/sync-oss.ts (5,420 repositories, 214 orgs, 784 pushes a day, 245,417 commits, each contributor's count), a day's count scaled by a made-up factor, and a star count that began at 1,420. The forge's database holds 1,150 repositories and 46 orgs; none of that was read. Its panels were tinted rgba over a stylesheet, DualStarButton showed its guess before GitHub answered, and BulkStarModal said "successfully starred" over a forge PUT. scripts/sync-oss.ts (was .mjs) now counts GitHub's public activity for the orgs we publish: commits a day, the year, the week, active repositories and the newest commits, written at each build with the moment it was read. A figure the snapshot does not hold is not drawn. The grid, bars and repository card are @hanzo/ui/marketing (8.29.19); the showcase stylesheet and DayActivityModal are deleted. The org and repo pages lose their typed "velocity" banners. Design lint is zero in components/oss, home/OpenSource and ForgeBetaBanner. e2e/gates/activity.spec.ts holds each figure to the snapshot, the cards to one fill and edge in both schemes, a card's name, line and foot to their own rows, and the phone to no sideways scroll. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
919 lines
34 KiB
TypeScript
919 lines
34 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* NO TAILWIND AND NO STYLE OBJECTS.
|
|
*
|
|
* Three rules carry what a utility used to. `@hanzo/ui` 8 reads PROPS, not
|
|
* classes, so a Button's fill and its hover come from `variant`/`size` and the
|
|
* classes that restated them are gone. A card that lifts on approach is
|
|
* `Leaf` — the site's one rule for exactly that — with `Lift` marking
|
|
* what inside brightens with it, which is what `group` + `group-hover:` were
|
|
* borrowing a parent's selector to say. A quiet control that brightens is
|
|
* `More`. A tag is `Chip`, the marketing badge, at the size the catalog's
|
|
* badges were drawn. Everything else is a gui prop over @hanzo/design's tokens.
|
|
*
|
|
* What the swap drops, and it is the same half every time: `Leaf` and `More`
|
|
* carry an EDGE and an INK, so a `hover:` that changed a FILL
|
|
* (`hover:bg-secondary/50`, `hover:bg-accent`) is not written back. A fill on
|
|
* approach would be a second rule, and this file is not the place to add one.
|
|
*
|
|
* The facet rail's `last:border-0 last:pb-0 last:mb-0` is gone rather than
|
|
* converted: the rail knows which section is last, so the rule between them is
|
|
* a property of the LIST, stated once by the thing that owns the relationship.
|
|
*/
|
|
|
|
import React, { useState, useMemo, type ReactNode } from 'react';
|
|
import Link from 'next/link';
|
|
import { motion } from '@/components/motion';
|
|
import { AnimatePresence } from 'framer-motion';
|
|
import { Search, Grid3X3, List, ChevronDown, X, Filter, BookOpen, GraduationCap, Rocket, Package, Check } from "lucide-react"
|
|
import { Github } from "@/components/brand"
|
|
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
Input,
|
|
} from "@hanzo/ui";
|
|
import {
|
|
ossCatalog,
|
|
featuredCollections,
|
|
repoTypes,
|
|
categories,
|
|
languages,
|
|
useCases,
|
|
maturityLevels,
|
|
deploymentTypes,
|
|
sortOptions,
|
|
filterRepos,
|
|
sortByHot,
|
|
sortByRecent,
|
|
type OSSRepo,
|
|
type RepoType,
|
|
type Maturity,
|
|
type DeploymentType,
|
|
type FeaturedCollection
|
|
} from "@/lib/data/oss-catalog";
|
|
import { Box, Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { Grid as Columns } from '@hanzo/ui/grid'
|
|
import { Chip, Eyebrow, Lede, More, Title, Leaf, Lift, Line } from '@hanzo/ui/marketing'
|
|
import { mix } from '@hanzo/app/lib/mix'
|
|
|
|
const MUTED = 'var(--muted-foreground)'
|
|
|
|
/** The two small rungs, each on the leading its utility paired it with. */
|
|
const XS = { fontSize: '$1', lineHeight: '1.3333' } as const
|
|
const SM = { fontSize: '$2', lineHeight: '1.4286' } as const
|
|
|
|
/** One line, cut at the measure rather than wrapped. */
|
|
const TRUNCATE = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } as const
|
|
|
|
/** How narrow a column may get before a grid drops one, per shape. */
|
|
const COLLECTIONS = { min: 220, max: 4 } as const
|
|
const RESULTS = { min: 280, max: 3 } as const
|
|
|
|
// =============================================================================
|
|
// QUICK FILTER CHIPS
|
|
// =============================================================================
|
|
|
|
const quickFilters = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'core', label: 'Core' },
|
|
{ value: 'template', label: 'Templates' },
|
|
{ value: 'example', label: 'Examples' },
|
|
{ value: 'connector', label: 'Connectors' },
|
|
{ value: 'mcp', label: 'MCP' },
|
|
{ value: 'sdk', label: 'SDKs' },
|
|
];
|
|
|
|
// =============================================================================
|
|
// REPO CARD COMPONENT
|
|
// =============================================================================
|
|
|
|
interface RepoCardProps {
|
|
repo: OSSRepo;
|
|
view: 'grid' | 'list';
|
|
}
|
|
|
|
/** The grounds and edges a tag is drawn in, as the tones they already were. */
|
|
const LIT = { bg: mix('var(--pure-white)', 10), borderColor: mix('var(--pure-white)', 30) } as const
|
|
const QUIET = { bg: mix('var(--pure-white)', 5), borderColor: 'var(--border)' } as const
|
|
const PLAIN = { bg: 'var(--neutral-800)', borderColor: 'var(--border)' } as const
|
|
const FAINT = { bg: mix('var(--neutral-800)', 50), borderColor: 'var(--border)' } as const
|
|
const TONE = {
|
|
featured: LIT,
|
|
recommended: LIT,
|
|
active: LIT,
|
|
experimental: QUIET,
|
|
stable: QUIET,
|
|
default: PLAIN,
|
|
faint: FAINT,
|
|
} as const
|
|
|
|
type Tone = keyof typeof TONE
|
|
|
|
/** A tag, as the catalog's badges read: an 11px semibold word on a 6px corner,
|
|
* its ground and edge the tone's. */
|
|
const Tag = ({ tone, children }: { tone: Tone; children: ReactNode }) => (
|
|
<Chip self="flex-start" shrink={0} justify="center" overflow="hidden" gap={6} px={10} py={4} rounded="$2" fontSize="$1" lineHeight="$1" fontWeight="600" color="$ink" {...TONE[tone]}>
|
|
{children}
|
|
</Chip>
|
|
)
|
|
|
|
/** The disc a repo's mark sits on. */
|
|
const Disc = ({ children }: { children: ReactNode }) => (
|
|
<XStack items="center" justify="center" width={40} height={40} shrink={0} rounded="var(--radius-lg)" bg="var(--neutral-800)">
|
|
{children}
|
|
</XStack>
|
|
)
|
|
|
|
/** An icon a control's label follows, with the room between them. */
|
|
const Lead = ({ children }: { children: React.ReactElement }) => <View render={children} mr={6} />
|
|
|
|
/** A card that IS a link's worth of hover: `Leaf` owns the edge, `Lift`
|
|
* marks what brightens with it. `--leaf-pad` is 0 because the children here
|
|
* carry their own room. */
|
|
const CARD = { p: 0, borderColor: "var(--border)", display: "flex", flexDirection: "column", overflow: "hidden", rounded: "var(--radius-xl)", bg: mix('var(--secondary)', 50), hoverStyle: { borderColor: "var(--border)" } } as const
|
|
|
|
const RepoCard: React.FC<RepoCardProps> = ({ repo, view }) => {
|
|
const Icon = repo.icon;
|
|
const ossHref = repo.slug.includes('/') ? `/oss/${repo.slug}` : `/oss/hanzoai/${repo.slug}`;
|
|
|
|
const primaryAction = repo.signals.deployable && repo.actions.deployUrl
|
|
? { label: 'Deploy', url: repo.actions.deployUrl, icon: Rocket }
|
|
: repo.type === 'template' && repo.actions.githubUrl
|
|
? { label: 'Use Template', url: repo.actions.githubUrl, icon: Package }
|
|
: null;
|
|
|
|
if (view === 'list') {
|
|
return (
|
|
<XStack
|
|
render={<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} />}
|
|
items="center"
|
|
gap={16}
|
|
p={16}
|
|
rounded="var(--radius-lg)"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg={mix('var(--secondary)', 50)}
|
|
>
|
|
{/* Icon */}
|
|
<Disc>
|
|
{Icon && <Icon width={20} height={20} color={MUTED} />}
|
|
</Disc>
|
|
|
|
{/* Content */}
|
|
<Box flex={1} minW={0}>
|
|
<XStack items="center" gap={8} mb={4}>
|
|
<Link href={ossHref}>
|
|
<Text render="h3" display="block" fontWeight="600" color="var(--foreground)" {...TRUNCATE}>{repo.name}</Text>
|
|
</Link>
|
|
{repo.signals.featured && (
|
|
<Tag tone="featured">
|
|
Featured
|
|
</Tag>
|
|
)}
|
|
</XStack>
|
|
<Line render="p" display="block" {...SM} color={MUTED} {...TRUNCATE}>{repo.description}</Line>
|
|
</Box>
|
|
|
|
{/* Badges */}
|
|
<XStack display="none" $md={{ display: 'flex' }} items="center" gap={8}>
|
|
{repo.languages.slice(0, 2).map(lang => (
|
|
<Tag key={lang} tone="default">
|
|
{lang}
|
|
</Tag>
|
|
))}
|
|
</XStack>
|
|
|
|
{/* Actions */}
|
|
<XStack items="center" gap={8}>
|
|
{primaryAction && (
|
|
<Button size="sm" asChild>
|
|
<a href={primaryAction.url || "#"} target="_blank" rel="noopener noreferrer">
|
|
{primaryAction.label}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{repo.actions.githubUrl && (
|
|
<Button size="sm" variant="ghost" asChild>
|
|
<a href={repo.actions.githubUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
|
|
<Github width={16} height={16} />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</XStack>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Leaf
|
|
render={<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} />}
|
|
{...CARD}
|
|
>
|
|
{/* Header */}
|
|
<Box p={20} pb={12}>
|
|
<XStack items="flex-start" justify="space-between" mb={12}>
|
|
<Disc>
|
|
{Icon && <Lift display="inline-flex" color={MUTED}><Icon width={20} height={20} /></Lift>}
|
|
</Disc>
|
|
<XStack gap={6}>
|
|
{repo.signals.featured && (
|
|
<Tag tone="featured">
|
|
Featured
|
|
</Tag>
|
|
)}
|
|
{repo.signals.recommended && !repo.signals.featured && (
|
|
<Tag tone="recommended">
|
|
Recommended
|
|
</Tag>
|
|
)}
|
|
<Tag tone={repo.maturity}>
|
|
{repo.maturity.charAt(0).toUpperCase() + repo.maturity.slice(1)}
|
|
</Tag>
|
|
</XStack>
|
|
</XStack>
|
|
|
|
<Link href={ossHref}>
|
|
<Text render="h3" display="block" mb={4} fontWeight="600" color="var(--foreground)">
|
|
{repo.name}
|
|
</Text>
|
|
</Link>
|
|
<Line render="p" display="block" mb={8} fontFamily="$mono" {...XS} color={MUTED}>{repo.slug}</Line>
|
|
<Line render="p" display="block" {...SM} color={MUTED} numberOfLines={2}>{repo.description}</Line>
|
|
</Box>
|
|
|
|
{/* Metadata */}
|
|
<XStack px={20} pb={12} flexWrap="wrap" gap={6}>
|
|
{repo.languages.slice(0, 3).map(lang => (
|
|
<Tag key={lang} tone="faint">
|
|
{lang}
|
|
</Tag>
|
|
))}
|
|
{repo.categories.slice(0, 1).map(cat => (
|
|
<Tag key={cat} tone="faint">
|
|
{cat}
|
|
</Tag>
|
|
))}
|
|
{repo.signals.deployable && (
|
|
<Tag tone="experimental">
|
|
Deployable
|
|
</Tag>
|
|
)}
|
|
</XStack>
|
|
|
|
{/* Upstream Attribution */}
|
|
{repo.upstream && (
|
|
<Box px={20} pb={12}>
|
|
<More
|
|
href={repo.upstream.url || "#"}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
fontSize="$1"
|
|
lineHeight="1.3333"
|
|
>
|
|
Powered by {repo.upstream.name} →
|
|
</More>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<XStack
|
|
mt="auto"
|
|
p={16}
|
|
pt={12}
|
|
borderTopWidth={1}
|
|
borderColor="var(--border)"
|
|
items="center"
|
|
gap={8}
|
|
>
|
|
{primaryAction ? (
|
|
<Button size="sm" flex={1} $touchable={{ minH: 44, minW: 44 }} asChild>
|
|
<a href={primaryAction.url || "#"} target="_blank" rel="noopener noreferrer">
|
|
<Lead><primaryAction.icon width={14} height={14} /></Lead>
|
|
{primaryAction.label}
|
|
</a>
|
|
</Button>
|
|
) : repo.actions.githubUrl ? (
|
|
<Button size="sm" variant="outline" flex={1} $touchable={{ minH: 44, minW: 44 }} asChild>
|
|
<a href={repo.actions.githubUrl} target="_blank" rel="noopener noreferrer">
|
|
<Lead><Github width={14} height={14} /></Lead>
|
|
View Source
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
|
|
<XStack gap={4}>
|
|
{repo.actions.docsUrl && (
|
|
<Button size="sm" variant="ghost" px={8} $touchable={{ minH: 44, minW: 44 }} asChild title="Docs">
|
|
<a href={repo.actions.docsUrl || "#"} target="_blank" rel="noopener noreferrer" aria-label="Docs">
|
|
<BookOpen width={16} height={16} />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{repo.actions.learnUrl && (
|
|
<Button size="sm" variant="ghost" px={8} $touchable={{ minH: 44, minW: 44 }} asChild title="Learn">
|
|
<a href={repo.actions.learnUrl || "#"} aria-label="Learn">
|
|
<GraduationCap width={16} height={16} />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{primaryAction && repo.actions.githubUrl && (
|
|
<Button size="sm" variant="ghost" px={8} $touchable={{ minH: 44, minW: 44 }} asChild title="GitHub">
|
|
<a href={repo.actions.githubUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
|
|
<Github width={16} height={16} />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</XStack>
|
|
</XStack>
|
|
</Leaf>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// FEATURED COLLECTION CARD
|
|
// =============================================================================
|
|
|
|
interface CollectionCardProps {
|
|
collection: FeaturedCollection;
|
|
onSelect: (repoIds: string[]) => void;
|
|
}
|
|
|
|
const COLLECTION = { p: 16, borderColor: "var(--border)", display: "flex", items: "center", gap: 12, text: "left", rounded: "var(--radius-xl)", bg: mix('var(--secondary)', 30), hoverStyle: { borderColor: mix('var(--pure-white)', 30) } } as const
|
|
|
|
const CollectionCard: React.FC<CollectionCardProps> = ({ collection, onSelect }) => {
|
|
const Icon = collection.icon;
|
|
|
|
return (
|
|
<Leaf
|
|
render="button"
|
|
onClick={() => onSelect(collection.repos)}
|
|
{...COLLECTION}
|
|
>
|
|
<Disc>
|
|
<Lift display="inline-flex" color={MUTED}><Icon width={20} height={20} /></Lift>
|
|
</Disc>
|
|
<Box>
|
|
<Lift render="h4" display="block" fontSize="$2" lineHeight="1.4286" fontWeight="500" color="var(--foreground)">
|
|
{collection.title}
|
|
</Lift>
|
|
<Line render="p" display="block" {...XS} color={MUTED}>{collection.description}</Line>
|
|
</Box>
|
|
</Leaf>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// FACET SECTION
|
|
// =============================================================================
|
|
|
|
interface FacetSectionProps {
|
|
title: string;
|
|
options: { value: string; label: string }[];
|
|
selected: string[];
|
|
onChange: (values: string[]) => void;
|
|
collapsed?: boolean;
|
|
}
|
|
|
|
const FacetSection: React.FC<FacetSectionProps> = ({
|
|
title,
|
|
options,
|
|
selected,
|
|
onChange,
|
|
collapsed = false,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(!collapsed);
|
|
|
|
const toggleOption = (value: string) => {
|
|
if (selected.includes(value)) {
|
|
onChange(selected.filter(v => v !== value));
|
|
} else {
|
|
onChange([...selected, value]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<XStack
|
|
render={<button onClick={() => setIsOpen(!isOpen)} />}
|
|
items="center"
|
|
justify="space-between"
|
|
width="100%"
|
|
minH={24}
|
|
mb={8}
|
|
$platform-web={{ textAlign: 'left' }}
|
|
>
|
|
<Line {...SM} fontWeight="500" color={mix('var(--foreground)', 80)}>{title}</Line>
|
|
<View render={<ChevronDown width={16} height={16} color={MUTED} />} rotate={isOpen ? '180deg' : undefined} transition="quickest" />
|
|
</XStack>
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<YStack
|
|
render={<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} />}
|
|
gap={4}
|
|
overflow="hidden"
|
|
>
|
|
{options.map(option => (
|
|
<More
|
|
render="button"
|
|
key={option.value}
|
|
onClick={() => toggleOption(option.value)}
|
|
width="100%"
|
|
px={8}
|
|
py={6}
|
|
rounded={4}
|
|
fontWeight="400"
|
|
{...(selected.includes(option.value) ? { bg: mix('var(--pure-white)', 10), color: 'var(--foreground)' } : null)}
|
|
>
|
|
{/* The box that says whether a facet option is on. */}
|
|
<View
|
|
render="span"
|
|
items="center"
|
|
justify="center"
|
|
width={16}
|
|
height={16}
|
|
rounded={4}
|
|
borderWidth={1}
|
|
borderStyle="solid"
|
|
{...(selected.includes(option.value)
|
|
? { borderColor: 'var(--pure-white)', bg: 'var(--pure-white)' }
|
|
: { borderColor: 'var(--neutral-600)' })}
|
|
>
|
|
{selected.includes(option.value) && (
|
|
<Check width={12} height={12} color="var(--foreground)" />
|
|
)}
|
|
</View>
|
|
{option.label}
|
|
</More>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
/**
|
|
* The rail, and the rule between its sections.
|
|
*
|
|
* The rule is a property of the LIST — which section has one is "every one but
|
|
* the last" — so it is stated here, once, rather than by each section reaching
|
|
* for a `:last-child` of its own.
|
|
*/
|
|
function FacetRail({ facets }: { facets: FacetSectionProps[] }) {
|
|
return (
|
|
<YStack gap={16}>
|
|
{facets.map((f, i) => (
|
|
<Box
|
|
key={f.title}
|
|
pb={i < facets.length - 1 ? 16 : 0}
|
|
borderBottomWidth={i < facets.length - 1 ? 1 : 0}
|
|
borderColor="var(--border)"
|
|
>
|
|
<FacetSection {...f} />
|
|
</Box>
|
|
))}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN CATALOG COMPONENT
|
|
// =============================================================================
|
|
|
|
const OSSCatalog: React.FC = () => {
|
|
// State
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [quickFilter, setQuickFilter] = useState('all');
|
|
const [sortBy, setSortBy] = useState('name');
|
|
const [view, setView] = useState<'grid' | 'list'>('grid');
|
|
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
|
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);
|
|
const [selectedUseCases, setSelectedUseCases] = useState<string[]>([]);
|
|
const [selectedMaturity, setSelectedMaturity] = useState<string[]>([]);
|
|
const [selectedDeployment, setSelectedDeployment] = useState<string[]>([]);
|
|
const [selectedCollection, setSelectedCollection] = useState<string[] | null>(null);
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
|
|
// Filter and sort repos
|
|
const filteredRepos = useMemo(() => {
|
|
let repos = ossCatalog;
|
|
|
|
// Collection filter
|
|
if (selectedCollection) {
|
|
repos = repos.filter(r => selectedCollection.includes(r.id));
|
|
}
|
|
|
|
// Quick filter
|
|
if (quickFilter !== 'all') {
|
|
repos = repos.filter(r => r.type === quickFilter);
|
|
}
|
|
|
|
// Facet filters
|
|
repos = filterRepos(repos, {
|
|
types: selectedTypes.length ? selectedTypes as RepoType[] : undefined,
|
|
categories: selectedCategories.length ? selectedCategories : undefined,
|
|
languages: selectedLanguages.length ? selectedLanguages : undefined,
|
|
useCases: selectedUseCases.length ? selectedUseCases : undefined,
|
|
maturity: selectedMaturity.length ? selectedMaturity as Maturity[] : undefined,
|
|
deployment: selectedDeployment.length ? selectedDeployment as DeploymentType[] : undefined,
|
|
search: searchQuery || undefined,
|
|
});
|
|
|
|
// Sort
|
|
switch (sortBy) {
|
|
case 'hot':
|
|
return sortByHot(repos);
|
|
case 'recent':
|
|
return sortByRecent(repos);
|
|
case 'beginner':
|
|
return [...repos].sort((a, b) => {
|
|
const aScore = (a.type === 'template' ? 2 : 0) + (a.maturity === 'stable' ? 1 : 0);
|
|
const bScore = (b.type === 'template' ? 2 : 0) + (b.maturity === 'stable' ? 1 : 0);
|
|
return bScore - aScore;
|
|
});
|
|
case 'name':
|
|
return [...repos].sort((a, b) => a.name.localeCompare(b.name));
|
|
default:
|
|
return sortByHot(repos);
|
|
}
|
|
}, [
|
|
searchQuery,
|
|
quickFilter,
|
|
sortBy,
|
|
selectedTypes,
|
|
selectedCategories,
|
|
selectedLanguages,
|
|
selectedUseCases,
|
|
selectedMaturity,
|
|
selectedDeployment,
|
|
selectedCollection,
|
|
]);
|
|
|
|
const featuredRepos = useMemo(() => {
|
|
if (selectedCollection || searchQuery || quickFilter !== 'all') return [];
|
|
return filteredRepos.filter(r => r.signals.featured).slice(0, 6);
|
|
}, [filteredRepos, selectedCollection, searchQuery, quickFilter]);
|
|
|
|
const mainRepos = useMemo(() => {
|
|
if (featuredRepos.length === 0) return filteredRepos;
|
|
const featuredIds = new Set(featuredRepos.map(r => r.id));
|
|
return filteredRepos.filter(r => !featuredIds.has(r.id));
|
|
}, [filteredRepos, featuredRepos]);
|
|
|
|
const hasActiveFilters =
|
|
selectedTypes.length > 0 ||
|
|
selectedCategories.length > 0 ||
|
|
selectedLanguages.length > 0 ||
|
|
selectedUseCases.length > 0 ||
|
|
selectedMaturity.length > 0 ||
|
|
selectedDeployment.length > 0;
|
|
|
|
const clearAllFilters = () => {
|
|
setSelectedTypes([]);
|
|
setSelectedCategories([]);
|
|
setSelectedLanguages([]);
|
|
setSelectedUseCases([]);
|
|
setSelectedMaturity([]);
|
|
setSelectedDeployment([]);
|
|
setSelectedCollection(null);
|
|
setQuickFilter('all');
|
|
setSearchQuery('');
|
|
};
|
|
|
|
// The six facets, as data — so the rail states the rule BETWEEN them once
|
|
// rather than each section reaching for a `:last-child` of its own.
|
|
const facets: FacetSectionProps[] = [
|
|
{ title: 'Type', options: repoTypes.map(t => ({ value: t.value, label: t.label })), selected: selectedTypes, onChange: setSelectedTypes },
|
|
{ title: 'Category', options: categories.map(c => ({ value: c, label: c })), selected: selectedCategories, onChange: setSelectedCategories },
|
|
{ title: 'Language', options: languages.map(l => ({ value: l, label: l })), selected: selectedLanguages, onChange: setSelectedLanguages },
|
|
{ title: 'Use Case', options: useCases.map(u => ({ value: u, label: u })), selected: selectedUseCases, onChange: setSelectedUseCases, collapsed: true },
|
|
{ title: 'Maturity', options: maturityLevels.map(m => ({ value: m.value, label: m.label })), selected: selectedMaturity, onChange: setSelectedMaturity, collapsed: true },
|
|
{ title: 'Deployment', options: deploymentTypes.map(d => ({ value: d.value, label: d.label })), selected: selectedDeployment, onChange: setSelectedDeployment, collapsed: true },
|
|
];
|
|
|
|
return (
|
|
/* This band is stated outright rather than taken from
|
|
`components/marketing/band`, and the reason is the RAIL: `Band` is
|
|
`overflow: hidden`, and an ancestor that is not `overflow: visible` is
|
|
itself the scrollport a `position: sticky` child sticks inside — so the
|
|
facet rail below would stop following the page and nothing would report
|
|
it. The floor (`min-height`) is the second thing that band cannot say. */
|
|
<Box
|
|
render="section"
|
|
minH="100vh"
|
|
bg="var(--background)"
|
|
py={64}
|
|
px={16}
|
|
$md={{ px: 32 }}
|
|
>
|
|
<Box width="100%" maxW={1280} mx="auto">
|
|
{/* Header */}
|
|
<Box mb={32}>
|
|
{/* `h2`. This catalog is a SECTION of /open-source, whose fold already
|
|
carries the page's one `h1`; two of them made the page claim two
|
|
subjects, and the count is exactly what the publish gates check. */}
|
|
<Title quiet mb={12}>
|
|
OSS Catalog
|
|
</Title>
|
|
<Lede mt="$3.5">
|
|
Open source tools, templates, and infrastructure for building AI applications
|
|
</Lede>
|
|
</Box>
|
|
|
|
{/* Featured Collections */}
|
|
{!selectedCollection && !searchQuery && quickFilter === 'all' && (
|
|
<Box mb={40}>
|
|
<Eyebrow render="h2" mb={16}>
|
|
Quick Start Collections
|
|
</Eyebrow>
|
|
<Columns columns={COLLECTIONS} gap={16}>
|
|
{featuredCollections.map(collection => (
|
|
<CollectionCard
|
|
key={collection.id}
|
|
collection={collection}
|
|
onSelect={setSelectedCollection}
|
|
/>
|
|
))}
|
|
</Columns>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Top Bar */}
|
|
<YStack gap={16} mb={24} $lg={{ flexDirection: 'row' }}>
|
|
{/* Search */}
|
|
<Box position="relative" flex={1}>
|
|
<View render={<Search width={16} height={16} color={MUTED} />} position="absolute" l={12} t="50%" y="-50%" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search repos, templates, tools..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
pl={40}
|
|
bg="var(--secondary)"
|
|
borderColor="var(--border)"
|
|
/>
|
|
</Box>
|
|
|
|
{/* Quick Filters */}
|
|
<XStack gap={8} pb={8} $lg={{ pb: 0 }} overflowX="auto">
|
|
{quickFilters.map(filter => (
|
|
<Button
|
|
key={filter.value}
|
|
variant={quickFilter === filter.value ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => {
|
|
setQuickFilter(filter.value);
|
|
setSelectedCollection(null);
|
|
}}
|
|
$platform-web={{ whiteSpace: 'nowrap' }}
|
|
>
|
|
{filter.label}
|
|
</Button>
|
|
))}
|
|
</XStack>
|
|
|
|
{/* Sort & View */}
|
|
<XStack gap={8}>
|
|
{/* v8: the popper lives on the menu ROOT, and `placement` replaces
|
|
Radix's side/align pair. Content is the portal + focus scope and
|
|
takes no styling — its chrome comes from the gui tokens. */}
|
|
<DropdownMenu placement="bottom-end">
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
{sortOptions.find(s => s.value === sortBy)?.label || 'Sort'}
|
|
<View render={<ChevronDown width={16} height={16} />} ml={8} />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
{sortOptions.map(option => (
|
|
<DropdownMenuItem
|
|
key={option.value}
|
|
onClick={() => setSortBy(option.value)}
|
|
cursor="pointer"
|
|
$platform-web={{ color: sortBy === option.value ? 'var(--foreground)' : undefined }}
|
|
>
|
|
{option.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* A control whose whole label is a glyph has to SAY what it does,
|
|
and a control that is a switch has to say which way it is set.
|
|
The darker background was the only thing saying so, and a screen
|
|
reader cannot read a background. */}
|
|
<XStack borderWidth={1} borderColor="var(--border)" rounded="var(--radius-md)">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label="Grid view"
|
|
aria-pressed={view === 'grid'}
|
|
onClick={() => setView('grid')}
|
|
px={8}
|
|
bg={view === 'grid' ? 'var(--neutral-800)' : undefined}
|
|
>
|
|
<Grid3X3 width={16} height={16} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label="List view"
|
|
aria-pressed={view === 'list'}
|
|
onClick={() => setView('list')}
|
|
px={8}
|
|
bg={view === 'list' ? 'var(--neutral-800)' : undefined}
|
|
>
|
|
<List width={16} height={16} />
|
|
</Button>
|
|
</XStack>
|
|
|
|
{/* Mobile Filter Button */}
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
display="flex"
|
|
$lg={{ display: 'none' }}
|
|
onClick={() => setFiltersOpen(true)}
|
|
>
|
|
<View render={<Filter width={16} height={16} />} mr={8} />
|
|
Filters
|
|
{hasActiveFilters && (
|
|
<Line display="flex" items="center" justify="center" ml={8} width={20} height={20} rounded="var(--radius-full)" bg="var(--pure-white)" {...XS} color="var(--primary-foreground)">
|
|
{selectedTypes.length + selectedCategories.length + selectedLanguages.length}
|
|
</Line>
|
|
)}
|
|
</Button>
|
|
{/* The mobile filter panel. It is a DISCLOSURE, not a gui Sheet:
|
|
that Sheet ignored `open` here and rendered its frame into the
|
|
top of the document, so a wall of checkboxes sat over the fold
|
|
on every visit, desktop included. This shows the SAME FacetRail
|
|
the sidebar shows — one filter UI, revealed in place. */}
|
|
{filtersOpen && (
|
|
<YStack
|
|
position="fixed"
|
|
l={0}
|
|
r={0}
|
|
b={0}
|
|
t={64}
|
|
z="var(--z-banner)"
|
|
borderTopWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="var(--background)"
|
|
$lg={{ display: 'none' }}
|
|
>
|
|
<XStack items="center" justify="space-between" borderBottomWidth={1} borderColor="var(--border)" px={16} py={12}>
|
|
<Line fontSize="$3" fontWeight="500" color="var(--foreground)" lineHeight="1.5">Filters</Line>
|
|
<Button variant="ghost" size="sm" onClick={() => setFiltersOpen(false)} aria-label="Close filters">
|
|
<X width={16} height={16} />
|
|
</Button>
|
|
</XStack>
|
|
<Box flex={1} p={16} pb={96} overflowY="auto">
|
|
<FacetRail facets={facets} />
|
|
</Box>
|
|
</YStack>
|
|
)}
|
|
</>
|
|
</XStack>
|
|
</YStack>
|
|
|
|
{/* Active Filters */}
|
|
{(hasActiveFilters || selectedCollection) && (
|
|
<XStack flexWrap="wrap" items="center" gap={8} mb={24}>
|
|
<Line {...SM} color={MUTED}>Active filters:</Line>
|
|
{selectedCollection && (
|
|
<Tag tone="featured">
|
|
Collection
|
|
<View render={<button onClick={() => setSelectedCollection(null)} aria-label="Clear the collection filter" />} ml={4}>
|
|
<X width={12} height={12} />
|
|
</View>
|
|
</Tag>
|
|
)}
|
|
{[...selectedTypes, ...selectedCategories, ...selectedLanguages].slice(0, 5).map(filter => (
|
|
<Tag key={filter} tone="default">
|
|
{filter}
|
|
<View
|
|
render={
|
|
<button
|
|
onClick={() => {
|
|
setSelectedTypes(selectedTypes.filter(t => t !== filter));
|
|
setSelectedCategories(selectedCategories.filter(c => c !== filter));
|
|
setSelectedLanguages(selectedLanguages.filter(l => l !== filter));
|
|
}}
|
|
aria-label={`Remove the ${filter} filter`}
|
|
/>
|
|
}
|
|
ml={4}
|
|
>
|
|
<X width={12} height={12} />
|
|
</View>
|
|
</Tag>
|
|
))}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearAllFilters}
|
|
>
|
|
Clear all
|
|
</Button>
|
|
</XStack>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
<XStack gap={32}>
|
|
{/* Facet Rail - Desktop */}
|
|
<Box render="aside" display="none" $lg={{ display: 'block', width: 256, shrink: 0 }}>
|
|
<Box
|
|
p={16}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
rounded="var(--radius-xl)"
|
|
bg="$panel"
|
|
position="sticky"
|
|
t={96}
|
|
>
|
|
<XStack items="center" justify="space-between" mb={16}>
|
|
<Text render="h3" display="block" fontWeight="500" color="var(--foreground)">Filters</Text>
|
|
{hasActiveFilters && (
|
|
<More
|
|
render="button"
|
|
onClick={clearAllFilters}
|
|
fontSize="$1"
|
|
lineHeight="1.3333"
|
|
>
|
|
Clear all
|
|
</More>
|
|
)}
|
|
</XStack>
|
|
<FacetRail facets={facets} />
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Results */}
|
|
<Box flex={1} minW={0}>
|
|
{/* Results Count */}
|
|
<XStack items="center" justify="space-between" mb={16}>
|
|
<Line render="p" display="block" {...SM} color={MUTED}>
|
|
{filteredRepos.length} {filteredRepos.length === 1 ? 'result' : 'results'}
|
|
</Line>
|
|
</XStack>
|
|
|
|
{/* Featured Row */}
|
|
{featuredRepos.length > 0 && (
|
|
<Box mb={32}>
|
|
<Eyebrow render="h2" display="flex" items="center" gap={8} mb={16}>
|
|
<View render="span" width={8} height={8} rounded="var(--radius-full)" bg="var(--pure-white)" />
|
|
Hanzo Picks
|
|
</Eyebrow>
|
|
{view === 'grid' ? (
|
|
<Columns columns={RESULTS} gap={16}>
|
|
{featuredRepos.map(repo => (
|
|
<RepoCard key={repo.id} repo={repo} view={view} />
|
|
))}
|
|
</Columns>
|
|
) : (
|
|
<YStack gap={12}>
|
|
{featuredRepos.map(repo => (
|
|
<RepoCard key={repo.id} repo={repo} view={view} />
|
|
))}
|
|
</YStack>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Main Results */}
|
|
{mainRepos.length > 0 ? (
|
|
view === 'grid' ? (
|
|
<Columns columns={RESULTS} gap={16}>
|
|
{mainRepos.map(repo => (
|
|
<RepoCard key={repo.id} repo={repo} view={view} />
|
|
))}
|
|
</Columns>
|
|
) : (
|
|
<YStack gap={12}>
|
|
{mainRepos.map(repo => (
|
|
<RepoCard key={repo.id} repo={repo} view={view} />
|
|
))}
|
|
</YStack>
|
|
)
|
|
) : (
|
|
<Box py={64} $platform-web={{ textAlign: 'center' }}>
|
|
<Text render="p" display="block" mb={16} color={MUTED}>No results found</Text>
|
|
<Button variant="outline" onClick={clearAllFilters}>
|
|
Clear filters
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</XStack>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default OSSCatalog;
|