mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[eric] marketplace: the packages tab wears the app's own tokens, a collection is one wide row, and the grid fills the width
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8afec84986
commit
7d96489f26
@@ -103,12 +103,14 @@ interface Props {
|
||||
sortOptions: PickerOption[];
|
||||
sortValue: string;
|
||||
onSort: (v: string) => void;
|
||||
// Optional left-hand content for the controls row; without it that band is empty with two pills pushed to the far right.
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
// The Directory's search row + chip/filter row, shared by both tabs (same chrome on claude.ai).
|
||||
const DirectoryFilterBar: React.FC<Props> = ({
|
||||
searchPlaceholder, query, onQuery,
|
||||
filterSections, filterSelected, onToggleFilter, sortOptions, sortValue, onSort,
|
||||
filterSections, filterSelected, onToggleFilter, sortOptions, sortValue, onSort, leading,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
@@ -135,7 +137,8 @@ const DirectoryFilterBar: React.FC<Props> = ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>{leading}</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<FilterPill sections={filterSections} selected={filterSelected} onToggle={onToggleFilter} />
|
||||
<SortPill options={sortOptions} value={sortValue} onChange={onSort} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -122,6 +123,20 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
}
|
||||
};
|
||||
|
||||
const sectionLabel = (label: string, count?: number): React.ReactElement | null => {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="baseline" sx={{ mb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, letterSpacing: '0.01em' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{typeof count === 'number' && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>{count}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const body = (): React.ReactElement => {
|
||||
if (loading && !loaded) {
|
||||
return (
|
||||
@@ -140,11 +155,9 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
return (
|
||||
<>
|
||||
{visibleBundles.length > 0 && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, color: c.text.tertiary, mb: 1 }}>
|
||||
Collections
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{sectionLabel('Collections')}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{visibleBundles.map((bundle) => (
|
||||
<PackageBundleCard
|
||||
key={bundle.id}
|
||||
@@ -156,7 +169,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start' }}>
|
||||
{visible.length > 0 && sectionLabel(visibleBundles.length > 0 ? 'All packages' : '', visible.length)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
// Fills the width at any card size, so an odd count leaves one gap rather than half a row.
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(272px, 1fr))',
|
||||
gap: 1.25,
|
||||
alignContent: 'start',
|
||||
}}
|
||||
>
|
||||
{visible.map((listing) => (
|
||||
<PackageCard
|
||||
key={listing.id}
|
||||
@@ -186,6 +208,13 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
]}
|
||||
sortValue={sort}
|
||||
onSort={setSort}
|
||||
leading={
|
||||
loaded ? (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
|
||||
{visible.length + visibleBundles.length} {visible.length + visibleBundles.length === 1 ? 'result' : 'results'}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{source === 'cache' && (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
|
||||
|
||||
@@ -2,11 +2,11 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2Outlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { KIND_LABELS, type Listing } from './catalog';
|
||||
import { type Listing } from './catalog';
|
||||
|
||||
interface Props {
|
||||
bundle: Listing;
|
||||
@@ -14,60 +14,58 @@ interface Props {
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
// Leads with a stacked-icon motif of its first few members and a count, so a bundle reads as a collection at a glance rather than as another single package.
|
||||
// A collection is one wide row, not a card in the package grid: it groups the things below it, and a
|
||||
// half-width card with empty space beside it reads as a layout that broke rather than a section.
|
||||
export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const preview = members.slice(0, 4);
|
||||
const kinds = Array.from(new Set(members.map((m) => KIND_LABELS[m.kind] || m.kind).filter(Boolean)));
|
||||
const names = members.map((m) => m.title).filter(Boolean);
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.75,
|
||||
cursor: 'pointer',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 2.5,
|
||||
border: `${c.border.width} solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
px: 2,
|
||||
py: 1.75,
|
||||
transition: c.transition,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
borderColor: c.accent.primary,
|
||||
boxShadow: c.shadow.md,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'&:hover': { borderColor: c.border.strong, boxShadow: c.shadow.sm },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2.5,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}1A`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bundle.icon_url ? (
|
||||
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Inventory2Icon sx={{ fontSize: 24, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: c.bg.secondary,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bundle.icon_url ? (
|
||||
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Inventory2Icon sx={{ fontSize: 20, color: c.text.tertiary }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="baseline" sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1.05rem',
|
||||
fontWeight: 660,
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 650,
|
||||
color: c.text.primary,
|
||||
letterSpacing: '-0.01em',
|
||||
letterSpacing: '-0.006em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -75,38 +73,35 @@ export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
|
||||
>
|
||||
{bundle.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, flexShrink: 0 }}>
|
||||
{members.length} {members.length === 1 ? 'package' : 'packages'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
color: c.text.tertiary,
|
||||
lineHeight: 1.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{bundle.description || names.join(', ') || 'A curated collection of packages.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
lineHeight: 1.5,
|
||||
mb: 1.75,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
minHeight: '2.6em',
|
||||
}}
|
||||
>
|
||||
{bundle.description || 'A curated collection of packages.'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={-0.75} sx={{ mt: 'auto', alignItems: 'center' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', flexShrink: 0 }}>
|
||||
{preview.map((m, i) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
title={m.title}
|
||||
sx={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 1.5,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: `${c.radius.sm}px`,
|
||||
flexShrink: 0,
|
||||
ml: i === 0 ? 0 : '-8px',
|
||||
ml: i === 0 ? 0 : '-7px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -119,29 +114,18 @@ export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
|
||||
{m.icon_url ? (
|
||||
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 15, color: c.text.muted }} />
|
||||
<ExtensionIcon sx={{ fontSize: 14, color: c.text.muted }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{members.length > preview.length && (
|
||||
<Typography sx={{ ml: 1, fontSize: '0.78rem', color: c.text.muted }}>
|
||||
+{members.length - preview.length} more
|
||||
<Typography sx={{ ml: 1, fontSize: '0.75rem', color: c.text.muted }}>
|
||||
+{members.length - preview.length}
|
||||
</Typography>
|
||||
)}
|
||||
{kinds.length > 0 && (
|
||||
<Chip
|
||||
label={kinds.slice(0, 2).join(' · ')}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
height: 22,
|
||||
fontSize: '0.72rem',
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<ChevronRightIcon sx={{ fontSize: 18, color: c.text.ghost, flexShrink: 0 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseTags, KIND_LABELS, type Listing } from './catalog';
|
||||
@@ -25,44 +24,41 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 2.5,
|
||||
border: `${c.border.width} solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
p: 2,
|
||||
transition: c.transition,
|
||||
'&:hover': {
|
||||
borderColor: c.border.medium,
|
||||
boxShadow: c.shadow.md,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
// No lift on hover: every other surface in this app answers with the border and a hairline shadow, and a card that jumps reads cheap next to them.
|
||||
'&:hover': { borderColor: c.border.strong, boxShadow: c.shadow.sm },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ mb: 1.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}14`,
|
||||
bgcolor: c.bg.secondary,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{listing.icon_url ? (
|
||||
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
|
||||
<ExtensionIcon sx={{ fontSize: 20, color: c.text.tertiary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 620,
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 650,
|
||||
color: c.text.primary,
|
||||
letterSpacing: '-0.01em',
|
||||
letterSpacing: '-0.006em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -70,47 +66,53 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
|
||||
>
|
||||
{listing.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, mt: 0.1 }}>
|
||||
{KIND_LABELS[listing.kind] || listing.kind || 'Package'}
|
||||
{listing.version ? ` · v${listing.version}` : ''}
|
||||
{listing.author ? ` · ${listing.author}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
lineHeight: 1.5,
|
||||
mb: 1.75,
|
||||
fontSize: '0.8438rem',
|
||||
color: c.text.tertiary,
|
||||
lineHeight: 1.55,
|
||||
mb: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
// Fixed two lines so every card in a row ends its body at the same baseline.
|
||||
minHeight: '2.6em',
|
||||
}}
|
||||
>
|
||||
{listing.description || 'No description provided.'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={0.75} sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{tags.map((t) => (
|
||||
<Chip
|
||||
<Box
|
||||
key={t}
|
||||
label={`#${t}`}
|
||||
size="small"
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
role="button"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onTag(t);
|
||||
}}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: '0.72rem',
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
'&:hover': { color: c.text.primary },
|
||||
px: 0.9,
|
||||
py: 0.2,
|
||||
borderRadius: `${c.radius.sm}px`,
|
||||
border: `${c.border.width} solid ${c.border.subtle}`,
|
||||
fontSize: '0.7188rem',
|
||||
color: c.text.muted,
|
||||
transition: c.transition,
|
||||
'&:hover': { color: c.text.secondary, borderColor: c.border.medium },
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{t}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user