Spaces:
Running
Running
| import { useState, useMemo } from 'react'; | |
| import { | |
| Box, | |
| Container, | |
| Typography, | |
| InputBase, | |
| Avatar, | |
| Chip, | |
| Checkbox, | |
| FormControlLabel, | |
| CircularProgress, | |
| Link, | |
| IconButton, | |
| } from '@mui/material'; | |
| import SearchIcon from '@mui/icons-material/Search'; | |
| import CloseIcon from '@mui/icons-material/Close'; | |
| import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; | |
| import AccessTimeIcon from '@mui/icons-material/AccessTime'; | |
| import OpenInNewIcon from '@mui/icons-material/OpenInNew'; | |
| import VerifiedIcon from '@mui/icons-material/Verified'; | |
| import Layout from '../components/Layout'; | |
| import ReachiesCarousel from '../components/ReachiesCarousel'; | |
| import { useApps } from '../context/AppsContext'; | |
| // App Card Component | |
| function AppCard({ app }) { | |
| const isOfficial = app.isOfficial; | |
| const cardData = app.cardData || {}; | |
| const author = app.id?.split('/')?.[0] || app.author || null; | |
| const likes = app.likes || 0; | |
| const lastModified = app.lastModified || app.createdAt || null; | |
| const emoji = cardData.emoji || '📦'; | |
| const formattedDate = lastModified | |
| ? new Date(lastModified).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) | |
| : null; | |
| const spaceUrl = `https://huggingface.co/spaces/${app.id}`; | |
| return ( | |
| <Box | |
| component={Link} | |
| href={spaceUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| sx={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| borderRadius: '16px', | |
| position: 'relative', | |
| overflow: 'hidden', | |
| bgcolor: '#ffffff', | |
| border: isOfficial ? '1px solid rgba(59, 130, 246, 0.25)' : '1px solid rgba(0, 0, 0, 0.08)', | |
| cursor: 'pointer', | |
| transition: 'all 0.2s ease', | |
| textDecoration: 'none', | |
| '&:hover': { | |
| transform: 'translateY(-4px)', | |
| boxShadow: '0 12px 40px rgba(0, 0, 0, 0.12)', | |
| borderColor: isOfficial ? 'rgba(59, 130, 246, 0.4)' : 'rgba(0, 0, 0, 0.15)', | |
| }, | |
| }} | |
| > | |
| {/* Top Bar with Author, Official Badge, and Likes */} | |
| <Box | |
| sx={{ | |
| px: 2.5, | |
| pt: 2, | |
| pb: 1.5, | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| borderBottom: '1px solid rgba(0, 0, 0, 0.06)', | |
| }} | |
| > | |
| {/* Author + Official Badge */} | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}> | |
| {author && ( | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}> | |
| <Avatar | |
| sx={{ | |
| width: 22, | |
| height: 22, | |
| bgcolor: isOfficial ? 'rgba(59, 130, 246, 0.15)' : 'rgba(0, 0, 0, 0.08)', | |
| fontSize: 11, | |
| fontWeight: 600, | |
| color: isOfficial ? '#FF9500' : '#1a1a1a', | |
| flexShrink: 0, | |
| }} | |
| > | |
| {author.charAt(0).toUpperCase()} | |
| </Avatar> | |
| <Typography | |
| sx={{ | |
| fontSize: 12, | |
| fontWeight: 500, | |
| color: '#666666', | |
| fontFamily: 'monospace', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| }} | |
| > | |
| {author} | |
| </Typography> | |
| </Box> | |
| )} | |
| {/* Official Badge - inline with author */} | |
| {isOfficial && ( | |
| <Chip | |
| icon={<VerifiedIcon sx={{ fontSize: 12 }} />} | |
| label="Official" | |
| size="small" | |
| sx={{ | |
| bgcolor: 'rgba(255, 149, 0, 0.1)', | |
| color: '#FF9500', | |
| fontWeight: 600, | |
| fontSize: 10, | |
| height: 20, | |
| flexShrink: 0, | |
| '& .MuiChip-icon': { | |
| color: '#FF9500', | |
| ml: 0.5, | |
| }, | |
| '& .MuiChip-label': { | |
| px: 0.75, | |
| }, | |
| }} | |
| /> | |
| )} | |
| </Box> | |
| {/* Likes */} | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}> | |
| <FavoriteBorderIcon sx={{ fontSize: 16, color: '#666666' }} /> | |
| <Typography | |
| sx={{ | |
| fontSize: 12, | |
| fontWeight: 600, | |
| color: '#666666', | |
| }} | |
| > | |
| {likes} | |
| </Typography> | |
| </Box> | |
| </Box> | |
| {/* Content */} | |
| <Box | |
| sx={{ | |
| px: 2.5, | |
| py: 2.5, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| flex: 1, | |
| }} | |
| > | |
| {/* Title + Emoji Row */} | |
| <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, mb: 1 }}> | |
| <Typography | |
| sx={{ | |
| fontSize: 17, | |
| fontWeight: 700, | |
| color: '#1a1a1a', | |
| letterSpacing: '-0.3px', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| flex: 1, | |
| }} | |
| > | |
| {app.name || app.id?.split('/').pop()} | |
| </Typography> | |
| <Typography | |
| component="span" | |
| sx={{ | |
| fontSize: 28, | |
| lineHeight: 1, | |
| flexShrink: 0, | |
| }} | |
| > | |
| {emoji} | |
| </Typography> | |
| </Box> | |
| {/* Description */} | |
| <Typography | |
| sx={{ | |
| fontSize: 13, | |
| color: '#666666', | |
| lineHeight: 1.5, | |
| display: '-webkit-box', | |
| WebkitLineClamp: 2, | |
| WebkitBoxOrient: 'vertical', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| mb: 2, | |
| flex: 1, | |
| }} | |
| > | |
| {cardData.short_description || app.description || 'No description'} | |
| </Typography> | |
| {/* Date + Open Link */} | |
| <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> | |
| {formattedDate && ( | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> | |
| <AccessTimeIcon sx={{ fontSize: 14, color: '#999' }} /> | |
| <Typography | |
| sx={{ | |
| fontSize: 11, | |
| fontWeight: 500, | |
| color: '#999', | |
| }} | |
| > | |
| {formattedDate} | |
| </Typography> | |
| </Box> | |
| )} | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#FF9500' }}> | |
| <Typography sx={{ fontSize: 12, fontWeight: 600 }}> | |
| View on HF | |
| </Typography> | |
| <OpenInNewIcon sx={{ fontSize: 14 }} /> | |
| </Box> | |
| </Box> | |
| </Box> | |
| </Box> | |
| ); | |
| } | |
| // Main Apps Page | |
| export default function Apps() { | |
| // Get apps from context (cached globally) | |
| const { apps, loading, error } = useApps(); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [officialOnly, setOfficialOnly] = useState(false); | |
| // Filter apps based on search and official toggle | |
| const filteredApps = useMemo(() => { | |
| let result = apps; | |
| // Filter by official | |
| if (officialOnly) { | |
| result = result.filter(app => app.isOfficial === true); | |
| } | |
| // Filter by search | |
| if (searchQuery.trim()) { | |
| const query = searchQuery.toLowerCase(); | |
| result = result.filter(app => | |
| app.name?.toLowerCase().includes(query) || | |
| app.id?.toLowerCase().includes(query) || | |
| app.description?.toLowerCase().includes(query) || | |
| app.cardData?.short_description?.toLowerCase().includes(query) | |
| ); | |
| } | |
| return result; | |
| }, [apps, searchQuery, officialOnly]); | |
| const isFiltered = searchQuery.trim() || officialOnly; | |
| return ( | |
| <Layout transparentHeader> | |
| {/* Hero Header */} | |
| <Box | |
| sx={{ | |
| background: 'linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%)', | |
| pt: { xs: 16, md: 18 }, | |
| pb: { xs: 10, md: 12 }, | |
| position: 'relative', | |
| overflow: 'visible', | |
| }} | |
| > | |
| {/* Gradient orbs */} | |
| <Box | |
| sx={{ | |
| position: 'absolute', | |
| top: '10%', | |
| right: '10%', | |
| width: 400, | |
| height: 400, | |
| background: 'radial-gradient(circle, rgba(236, 72, 153, 0.15) 0%, transparent 60%)', | |
| filter: 'blur(80px)', | |
| pointerEvents: 'none', | |
| }} | |
| /> | |
| <Box | |
| sx={{ | |
| position: 'absolute', | |
| bottom: '-20%', | |
| left: '5%', | |
| width: 350, | |
| height: 350, | |
| background: 'radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 60%)', | |
| filter: 'blur(60px)', | |
| pointerEvents: 'none', | |
| }} | |
| /> | |
| <Container maxWidth="lg" sx={{ position: 'relative', zIndex: 10 }}> | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: { xs: 4, md: 6 }, | |
| flexDirection: { xs: 'column', md: 'row' }, | |
| }} | |
| > | |
| {/* Reachies Carousel */} | |
| <Box sx={{ flexShrink: 0 }}> | |
| <ReachiesCarousel | |
| width={200} | |
| height={200} | |
| interval={2500} | |
| fadeInDuration={400} | |
| fadeOutDuration={150} | |
| zoom={1.6} | |
| verticalAlign="60%" | |
| /> | |
| </Box> | |
| {/* Text content */} | |
| <Box sx={{ textAlign: { xs: 'center', md: 'left' } }}> | |
| <Box | |
| sx={{ | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 0.75, | |
| px: 2, | |
| py: 0.75, | |
| mb: 2, | |
| borderRadius: 50, | |
| backgroundColor: 'rgba(255,255,255,0.1)', | |
| border: '1px solid rgba(255,255,255,0.15)', | |
| backdropFilter: 'blur(10px)', | |
| }} | |
| > | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| color: 'rgba(255,255,255,0.7)', | |
| fontWeight: 500, | |
| fontSize: 11, | |
| }} | |
| > | |
| Powered by | |
| </Typography> | |
| <Box component="img" src="/assets/hf-logo.svg" alt="Hugging Face" sx={{ height: 14 }} /> | |
| </Box> | |
| <Typography | |
| variant="h1" | |
| component="h1" | |
| sx={{ | |
| color: 'white', | |
| fontWeight: 700, | |
| mb: 3, | |
| fontSize: { xs: 36, md: 52 }, | |
| background: 'linear-gradient(135deg, #ffffff 0%, rgba(255,255,255,0.8) 100%)', | |
| WebkitBackgroundClip: 'text', | |
| WebkitTextFillColor: 'transparent', | |
| }} | |
| > | |
| Applications | |
| </Typography> | |
| <Typography | |
| variant="h6" | |
| sx={{ | |
| color: 'rgba(255,255,255,0.7)', | |
| fontWeight: 400, | |
| maxWidth: 550, | |
| lineHeight: 1.7, | |
| }} | |
| > | |
| Discover apps built by the community and official apps from Pollen Robotics. | |
| Install them directly from the Reachy Mini desktop app. | |
| </Typography> | |
| </Box> | |
| </Box> | |
| </Container> | |
| </Box> | |
| {/* Search Section */} | |
| <Container maxWidth="lg" sx={{ mt: -4, mb: 4, position: 'relative', zIndex: 10 }}> | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 2, | |
| px: 3, | |
| py: 2, | |
| borderRadius: '16px', | |
| bgcolor: 'white', | |
| boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)', | |
| border: '1px solid rgba(0, 0, 0, 0.06)', | |
| }} | |
| > | |
| <SearchIcon sx={{ fontSize: 22, color: '#999' }} /> | |
| <InputBase | |
| placeholder="Search apps by name or description..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| sx={{ | |
| flex: 1, | |
| fontSize: 15, | |
| fontWeight: 500, | |
| color: '#333', | |
| '& input::placeholder': { | |
| color: '#999', | |
| opacity: 1, | |
| }, | |
| }} | |
| /> | |
| {/* Clear search */} | |
| {searchQuery && ( | |
| <IconButton | |
| onClick={() => setSearchQuery('')} | |
| size="small" | |
| sx={{ color: '#999' }} | |
| > | |
| <CloseIcon sx={{ fontSize: 20 }} /> | |
| </IconButton> | |
| )} | |
| {/* Separator */} | |
| <Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} /> | |
| {/* Apps count */} | |
| <Typography | |
| sx={{ | |
| fontSize: 13, | |
| fontWeight: 700, | |
| color: isFiltered ? '#FF9500' : '#999', | |
| px: 1.5, | |
| py: 0.5, | |
| borderRadius: '8px', | |
| bgcolor: isFiltered ? 'rgba(255, 149, 0, 0.1)' : 'rgba(0, 0, 0, 0.03)', | |
| }} | |
| > | |
| {isFiltered ? `${filteredApps.length}/${apps.length}` : apps.length} | |
| </Typography> | |
| {/* Separator */} | |
| <Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} /> | |
| {/* Official toggle */} | |
| <FormControlLabel | |
| control={ | |
| <Checkbox | |
| checked={officialOnly} | |
| onChange={(e) => setOfficialOnly(e.target.checked)} | |
| size="small" | |
| sx={{ | |
| color: '#999', | |
| '&.Mui-checked': { | |
| color: '#FF9500', | |
| }, | |
| }} | |
| /> | |
| } | |
| label={ | |
| <Typography sx={{ fontSize: 13, fontWeight: 600, color: '#666' }}> | |
| Official only | |
| </Typography> | |
| } | |
| sx={{ m: 0 }} | |
| /> | |
| </Box> | |
| </Container> | |
| {/* Apps Grid */} | |
| <Container maxWidth="lg" sx={{ pb: 10 }}> | |
| {loading ? ( | |
| <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}> | |
| <CircularProgress sx={{ color: '#FF9500' }} /> | |
| </Box> | |
| ) : error ? ( | |
| <Box sx={{ textAlign: 'center', py: 10 }}> | |
| <Typography color="error">{error}</Typography> | |
| </Box> | |
| ) : filteredApps.length === 0 ? ( | |
| <Box sx={{ textAlign: 'center', py: 10 }}> | |
| <Typography sx={{ fontSize: 48, mb: 2 }}>🔍</Typography> | |
| <Typography variant="h6" sx={{ color: '#666' }}> | |
| No apps found | |
| </Typography> | |
| <Typography sx={{ color: '#999' }}> | |
| Try adjusting your search or filters | |
| </Typography> | |
| </Box> | |
| ) : ( | |
| <Box | |
| sx={{ | |
| display: 'grid', | |
| gridTemplateColumns: { | |
| xs: '1fr', | |
| sm: 'repeat(2, 1fr)', | |
| md: 'repeat(3, 1fr)', | |
| }, | |
| gap: 3, | |
| }} | |
| > | |
| {filteredApps.map((app, index) => ( | |
| <AppCard | |
| key={app.id || index} | |
| app={app} | |
| /> | |
| ))} | |
| </Box> | |
| )} | |
| </Container> | |
| </Layout> | |
| ); | |
| } | |