Reachy_Mini / src /pages /Apps.jsx
tfrere's picture
tfrere HF Staff
Initial commit: Reachy Mini Website
5c85958
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>
);
}