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({text.slice(cursor, start)});
}
parts.push(
{text.slice(start, end + 1)}
);
cursor = end + 1;
}
if (cursor < text.length) {
parts.push({text.slice(cursor)});
}
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 (
{/* Top Bar with Author, Official Badge, and Likes */}
{/* Author + Official Badge */}
{author && (
{author.charAt(0).toUpperCase()}
)}
{/* Official Badge - inline with author */}
{isOfficial && (
}
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 && (
)}
{/* Likes - Interactive */}
{
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 ? (
) : (
)}
{displayedLikes}
{/* Content */}
{/* Title + Emoji Row */}
{emoji}
{/* Description */}
{/* Date + Install Button */}
{/* Date */}
{formattedDate ? (
{formattedDate}
) : (
)}
{/* Action Button */}
:
}
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'}
);
});
// 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 (
<>
setValue(e.target.value)}
sx={{
flex: 1,
fontSize: 15,
fontWeight: 500,
color: '#333',
'& input::placeholder': {
color: '#999',
opacity: 1,
},
}}
/>
{value && (
{ setValue(''); onSearch(''); }}
size="small"
sx={{ color: '#999' }}
>
)}
>
);
});
// 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 (
{/* Hero Header */}
{/* Gradient orbs */}
{/* Reachies Carousel */}
{/* Text content */}
Powered by
Applications
Discover apps built by the community and official apps from Pollen Robotics.
Install them directly from the Reachy Mini desktop app.
{/* Search Section */}
{/* Separator */}
{/* Apps count */}
{isFiltered ? `${filteredApps.length}/${apps.length}` : apps.length}
{/* Separator */}
{/* Official toggle */}
setOfficialOnly(e.target.checked)}
size="small"
sx={{
color: '#999',
'&.Mui-checked': {
color: '#FF9500',
},
}}
/>
}
label={
Official only
}
sx={{ m: 0 }}
/>
{/* Auth: Login / User (only show when OAuth is available) */}
{isOAuthAvailable && (
)}
{isLoggedIn ? (
{user?.name?.charAt(0)?.toUpperCase()}
{user?.preferredUsername || user?.name}
) : isOAuthAvailable ? (
) : null}
{/* Category Tags */}
{!loading && categories.length > 0 && (
Tags
{/* "All" chip */}
All
({officialOnly ? apps.filter((a) => a.isOfficial).length : apps.length})
}
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 (
{formatTagName(cat.name)}
({cat.count})
}
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 },
}}
/>
);
})}
)}
{/* Apps Grid */}
{loading ? (
) : error ? (
{error}
) : filteredApps.length === 0 ? (
🔍
No apps found
Try adjusting your search or filters
) : (
{filteredApps.map((app, index) => (
))}
)}
{/* Install Modal */}
);
}