Reachy_Mini / src /pages /Apps.jsx
tfrere's picture
tfrere HF Staff
feat: highlight fuzzy search matches on app cards
c28008c
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react';
import {
Box,
Container,
Typography,
InputBase,
Avatar,
Chip,
Checkbox,
FormControlLabel,
CircularProgress,
Link,
IconButton,
Button,
Tooltip,
} 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 FavoriteIcon from '@mui/icons-material/Favorite';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import VerifiedIcon from '@mui/icons-material/Verified';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import LogoutIcon from '@mui/icons-material/Logout';
import Layout from '../components/Layout';
import ReachiesCarousel from '../components/ReachiesCarousel';
import { useApps } from '../context/AppsContext';
import { useAuth } from '../context/AuthContext';
import InstallModal from '../components/InstallModal';
/**
* Render text with highlighted match ranges from Fuse.js.
* indices is an array of [start, end] pairs.
*/
function HighlightText({ text, indices }) {
if (!text) return null;
if (!indices || indices.length === 0) return text;
// Only keep matches that span at least 2 characters
const significant = indices.filter(([start, end]) => end - start >= 1);
if (significant.length === 0) return text;
// Merge overlapping / adjacent ranges and sort
const sorted = [...significant].sort((a, b) => a[0] - b[0]);
const merged = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prev = merged[merged.length - 1];
if (sorted[i][0] <= prev[1] + 1) {
prev[1] = Math.max(prev[1], sorted[i][1]);
} else {
merged.push(sorted[i]);
}
}
const parts = [];
let cursor = 0;
for (const [start, end] of merged) {
if (cursor < start) {
parts.push(<span key={`t${cursor}`}>{text.slice(cursor, start)}</span>);
}
parts.push(
<span
key={`h${start}`}
style={{
backgroundColor: 'rgba(255, 149, 0, 0.18)',
color: '#b36b00',
borderRadius: 2,
padding: '0 1px',
}}
>
{text.slice(start, end + 1)}
</span>
);
cursor = end + 1;
}
if (cursor < text.length) {
parts.push(<span key={`t${cursor}`}>{text.slice(cursor)}</span>);
}
return <>{parts}</>;
}
// App Card Component (memoized to avoid re-renders when only search changes)
const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn, matchData }) {
const isOfficial = app.isOfficial;
const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
const cardData = app.extra?.cardData || {};
const author = app.extra?.author || app.id?.split('/')?.[0] || null;
const baseLikes = app.extra?.likes || 0;
const lastModified = app.extra?.lastModified || null;
const emoji = cardData.emoji || (isPythonApp ? '📦' : '🌐');
const spaceUrl = app.url || `https://huggingface.co/spaces/${app.id}`;
// Compute displayed likes: adjust based on like state vs original data
const displayedLikes = baseLikes + (isLiked ? 1 : 0);
const formattedDate = lastModified
? new Date(lastModified).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const handleCardClick = () => {
if (isPythonApp) {
onInstallClick?.(app);
} else {
// Web app: open Space in new tab
window.open(spaceUrl, '_blank', 'noopener,noreferrer');
}
};
const handleButtonClick = (e) => {
e.preventDefault();
e.stopPropagation();
if (isPythonApp) {
onInstallClick?.(app);
} else {
window.open(spaceUrl, '_blank', 'noopener,noreferrer');
}
};
return (
<Box
onClick={handleCardClick}
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)',
transition: 'all 0.2s ease',
cursor: 'pointer',
'&: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',
}}
>
<HighlightText text={author} indices={matchData?._searchAuthor} />
</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,
},
}}
/>
)}
{/* Web App Badge */}
{!isPythonApp && (
<Chip
label="Web"
size="small"
sx={{
bgcolor: 'rgba(99, 102, 241, 0.1)',
color: '#6366f1',
fontWeight: 600,
fontSize: 10,
height: 20,
flexShrink: 0,
'& .MuiChip-label': {
px: 0.75,
},
}}
/>
)}
</Box>
{/* Likes - Interactive */}
<Tooltip
title={isLoggedIn ? '' : 'Sign in to like this app'}
arrow
placement="top"
disableHoverListener={isLoggedIn}
>
<Box
component="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLike?.(app.id);
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexShrink: 0,
border: 'none',
bgcolor: 'transparent',
cursor: 'pointer',
p: 0.5,
borderRadius: '6px',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: 'rgba(236, 72, 153, 0.08)',
},
}}
>
{isLiked ? (
<FavoriteIcon sx={{ fontSize: 16, color: '#ec4899' }} />
) : (
<FavoriteBorderIcon sx={{ fontSize: 16, color: '#666666' }} />
)}
<Typography
sx={{
fontSize: 12,
fontWeight: 600,
color: isLiked ? '#ec4899' : '#666666',
}}
>
{displayedLikes}
</Typography>
</Box>
</Tooltip>
</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,
}}
>
<HighlightText
text={app.name || app.id?.split('/').pop()}
indices={matchData?.name}
/>
</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,
}}
>
<HighlightText
text={cardData.short_description || app.description || 'No description'}
indices={matchData?._searchDescription}
/>
</Typography>
{/* Date + Install Button */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* Date */}
{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 />
)}
{/* Action Button */}
<Button
variant="text"
size="small"
endIcon={isPythonApp
? <DownloadIcon sx={{ fontSize: 14 }} />
: <OpenInNewIcon sx={{ fontSize: 14 }} />
}
onClick={handleButtonClick}
sx={{
py: 0.5,
px: 1.5,
borderRadius: '8px',
color: isPythonApp ? '#FF9500' : '#6366f1',
fontWeight: 600,
fontSize: 12,
textTransform: 'none',
'&:hover': {
bgcolor: isPythonApp ? 'rgba(255, 149, 0, 0.08)' : 'rgba(99, 102, 241, 0.08)',
},
}}
>
{isPythonApp ? 'Install' : 'Open'}
</Button>
</Box>
</Box>
</Box>
);
});
// Isolated search input — typing only re-renders this component, not the whole page
const SearchInput = memo(function SearchInput({ onSearch }) {
const [value, setValue] = useState('');
const debounceRef = useRef(null);
useEffect(() => {
clearTimeout(debounceRef.current);
if (!value.trim()) {
onSearch('');
return;
}
debounceRef.current = setTimeout(() => onSearch(value.trim()), 200);
return () => clearTimeout(debounceRef.current);
}, [value, onSearch]);
return (
<>
<SearchIcon sx={{ fontSize: 22, color: '#999' }} />
<InputBase
placeholder="Search apps by name, description, tags..."
value={value}
onChange={(e) => setValue(e.target.value)}
sx={{
flex: 1,
fontSize: 15,
fontWeight: 500,
color: '#333',
'& input::placeholder': {
color: '#999',
opacity: 1,
},
}}
/>
{value && (
<IconButton
onClick={() => { setValue(''); onSearch(''); }}
size="small"
sx={{ color: '#999' }}
>
<CloseIcon sx={{ fontSize: 20 }} />
</IconButton>
)}
</>
);
});
// Tags to exclude from category filters
const EXCLUDED_TAGS = new Set([
'reachy_mini', 'reachy-mini', 'reachy_mini_python_app',
'static', 'docker', 'region:us', 'region:eu',
]);
// Format a tag name for display (e.g. "reachy_mini_game" → "Reachy Mini Game")
function formatTagName(tag) {
if (tag.startsWith('sdk:')) {
const sdk = tag.replace('sdk:', '');
return sdk.charAt(0).toUpperCase() + sdk.slice(1).toLowerCase();
}
return tag
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
// Main Apps Page
export default function Apps() {
// Get apps from context (cached globally)
const { apps, loading, error } = useApps();
const { user, isLoggedIn, isOAuthAvailable, login, logout, isSpaceLiked, toggleLike } = useAuth();
const [officialOnly, setOfficialOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState(null);
const [searchResults, setSearchResults] = useState(null); // null = no search, [] = no matches
const [isSearching, setIsSearching] = useState(false);
const workerRef = useRef(null);
// Initialize search Web Worker
useEffect(() => {
workerRef.current = new Worker(
new URL('../workers/searchWorker.js', import.meta.url),
{ type: 'module' }
);
workerRef.current.onmessage = (e) => {
if (e.data.type === 'RESULTS') {
setSearchResults(e.data.results);
}
};
return () => workerRef.current?.terminate();
}, []);
// Send apps to worker to build index whenever apps change
useEffect(() => {
if (workerRef.current && apps.length > 0) {
workerRef.current.postMessage({ type: 'INDEX', apps });
}
}, [apps]);
// Callback from SearchInput component (already debounced)
const handleSearch = useCallback((query) => {
if (!query) {
setSearchResults(null);
setIsSearching(false);
return;
}
setIsSearching(true);
workerRef.current?.postMessage({ type: 'SEARCH', query });
}, []);
// Install modal state
const [installModalOpen, setInstallModalOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
const handleInstallClick = (app) => {
setSelectedApp(app);
setInstallModalOpen(true);
};
const handleCloseInstallModal = () => {
setInstallModalOpen(false);
setSelectedApp(null);
};
const handleToggleLike = useCallback(
(spaceId) => {
toggleLike(spaceId);
},
[toggleLike]
);
// Extract available categories from app tags, sorted by count (top 8)
const categories = useMemo(() => {
const categoryMap = new Map();
// Use only apps matching current mode (official toggle)
const baseApps = officialOnly ? apps.filter((a) => a.isOfficial) : apps;
baseApps.forEach((app) => {
const rootTags = app.extra?.tags || [];
const cardDataTags = app.extra?.cardData?.tags || [];
const allTags = [...new Set([...rootTags, ...cardDataTags])];
const sdk = app.extra?.sdk || app.extra?.cardData?.sdk;
allTags.forEach((tag) => {
if (
tag &&
typeof tag === 'string' &&
!tag.startsWith('region:') &&
!EXCLUDED_TAGS.has(tag.toLowerCase())
) {
categoryMap.set(tag, (categoryMap.get(tag) || 0) + 1);
}
});
// Add SDK as category if not already covered by a tag
if (sdk && typeof sdk === 'string') {
const hasMatchingTag = allTags.some(
(t) => t && typeof t === 'string' && t.toLowerCase() === sdk.toLowerCase()
);
if (!hasMatchingTag) {
const sdkKey = `sdk:${sdk}`;
categoryMap.set(sdkKey, (categoryMap.get(sdkKey) || 0) + 1);
}
}
});
return Array.from(categoryMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => (b.count !== a.count ? b.count - a.count : a.name.localeCompare(b.name)))
.slice(0, 8);
}, [apps, officialOnly]);
// Filter apps based on worker search results, official toggle, and category
const filteredApps = useMemo(() => {
let result = apps;
// Filter by official
if (officialOnly) {
result = result.filter((app) => app.isOfficial === true);
}
// Filter by category
if (selectedCategory) {
result = result.filter((app) => {
const rootTags = app.extra?.tags || [];
const cardDataTags = app.extra?.cardData?.tags || [];
const allTags = [...new Set([...rootTags, ...cardDataTags])];
const sdk = app.extra?.sdk || app.extra?.cardData?.sdk;
if (selectedCategory.startsWith('sdk:')) {
return sdk === selectedCategory.replace('sdk:', '');
}
const tagMatch = allTags.some(
(t) => t && typeof t === 'string' && t.toLowerCase() === selectedCategory.toLowerCase()
);
const sdkMatch =
sdk && typeof sdk === 'string' && sdk.toLowerCase() === selectedCategory.toLowerCase();
return tagMatch || sdkMatch;
});
}
// Apply fuzzy search results from worker
if (searchResults !== null) {
const scoreMap = new Map(searchResults.map((r) => [r.id, r.score]));
const matchedIds = new Set(searchResults.map((r) => r.id));
result = result.filter((app) => matchedIds.has(app.id));
result.sort((a, b) => (scoreMap.get(a.id) || 1) - (scoreMap.get(b.id) || 1));
return result;
}
// Default sort: by likes (descending)
result.sort((a, b) => (b.extra?.likes || 0) - (a.extra?.likes || 0));
return result;
}, [apps, officialOnly, selectedCategory, searchResults]);
// Build a map of app ID → match highlight data
const matchDataMap = useMemo(() => {
if (!searchResults) return null;
const map = new Map();
for (const r of searchResults) {
map.set(r.id, r.matches);
}
return map;
}, [searchResults]);
const isFiltered = searchResults !== null || officialOnly || selectedCategory;
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: 1, position: 'relative', zIndex: 10 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 2,
mb: 2,
borderRadius: '16px',
bgcolor: 'white',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}
>
<SearchInput onSearch={handleSearch} />
{/* 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 }}
/>
{/* Auth: Login / User (only show when OAuth is available) */}
{isOAuthAvailable && (
<Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} />
)}
{isLoggedIn ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
src={user?.avatarUrl}
sx={{
width: 28,
height: 28,
fontSize: 12,
fontWeight: 600,
bgcolor: '#FF9500',
}}
>
{user?.name?.charAt(0)?.toUpperCase()}
</Avatar>
<Typography
sx={{
fontSize: 13,
fontWeight: 600,
color: '#333',
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user?.preferredUsername || user?.name}
</Typography>
<Tooltip title="Sign out">
<IconButton onClick={logout} size="small" sx={{ color: '#999' }}>
<LogoutIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Box>
) : isOAuthAvailable ? (
<Button
onClick={login}
variant="text"
size="small"
sx={{
textTransform: 'none',
fontWeight: 600,
fontSize: 13,
color: '#333',
gap: 0.75,
px: 1.5,
borderRadius: '8px',
whiteSpace: 'nowrap',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.04)',
},
}}
>
<Box
component="img"
src="/assets/hf-logo.svg"
alt=""
sx={{ height: 16 }}
/>
Sign in
</Button>
) : null}
</Box>
</Container>
{/* Category Tags */}
{!loading && categories.length > 0 && (
<Container maxWidth="lg" sx={{ mt: 1, mb: 4 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
<Typography
sx={{
fontSize: 12,
fontWeight: 600,
color: '#999',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Tags
</Typography>
{/* "All" chip */}
<Chip
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<span>All</span>
<Typography
component="span"
sx={{
fontSize: 11,
fontWeight: 600,
color: !selectedCategory ? '#FF9500' : '#999',
opacity: 0.8,
}}
>
({officialOnly ? apps.filter((a) => a.isOfficial).length : apps.length})
</Typography>
</Box>
}
onClick={() => setSelectedCategory(null)}
size="small"
sx={{
height: 28,
fontSize: 12,
fontWeight: !selectedCategory ? 700 : 500,
bgcolor: !selectedCategory
? 'rgba(255, 149, 0, 0.12)'
: 'rgba(0, 0, 0, 0.04)',
color: !selectedCategory ? '#FF9500' : '#666',
border: !selectedCategory
? '1px solid rgba(255, 149, 0, 0.4)'
: '1px solid rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: !selectedCategory
? 'rgba(255, 149, 0, 0.18)'
: 'rgba(0, 0, 0, 0.08)',
},
'& .MuiChip-label': { px: 1.5 },
}}
/>
{/* Category chips */}
{categories.map((cat) => {
const isSelected = selectedCategory === cat.name;
return (
<Chip
key={cat.name}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<span>{formatTagName(cat.name)}</span>
<Typography
component="span"
sx={{
fontSize: 11,
fontWeight: 600,
color: isSelected ? '#FF9500' : '#999',
opacity: 0.8,
}}
>
({cat.count})
</Typography>
</Box>
}
onClick={() => setSelectedCategory(isSelected ? null : cat.name)}
size="small"
sx={{
height: 28,
fontSize: 12,
fontWeight: isSelected ? 700 : 500,
bgcolor: isSelected
? 'rgba(255, 149, 0, 0.12)'
: 'rgba(0, 0, 0, 0.04)',
color: isSelected ? '#FF9500' : '#666',
border: isSelected
? '1px solid rgba(255, 149, 0, 0.4)'
: '1px solid rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isSelected
? 'rgba(255, 149, 0, 0.18)'
: 'rgba(0, 0, 0, 0.08)',
},
'& .MuiChip-label': { px: 1.5 },
}}
/>
);
})}
</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}
onInstallClick={handleInstallClick}
isLiked={isSpaceLiked(app.id)}
onToggleLike={handleToggleLike}
isLoggedIn={isLoggedIn}
matchData={matchDataMap?.get(app.id) || null}
/>
))}
</Box>
)}
</Container>
{/* Install Modal */}
<InstallModal
open={installModalOpen}
onClose={handleCloseInstallModal}
app={selectedApp}
/>
</Layout>
);
}