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 */} ); }); // 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 */} ); }