| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useState, useEffect, useCallback, useMemo } from 'react'; |
| import { Search, Download, RefreshCw, Globe, AlertTriangle, Package, User, Loader2, Info, X, Sparkles, BookOpen, Wrench, } from 'lucide-react'; |
| import { communityStatus, communityRegistry, communityDownloadPackage, communityCard, } from './communityApi'; |
| import { previewPersonaPackage, importPersonaPackage, importPersonaAtomic } from './personaPortability'; |
| |
| |
| |
| function GalleryCard({ item, installing, onInstall, onDetail, }) { |
| const size = item.latest?.size_bytes; |
| const sizeLabel = size |
| ? size < 1048576 |
| ? `${(size / 1024).toFixed(0)} KB` |
| : `${(size / 1048576).toFixed(1)} MB` |
| : null; |
| return (<div className="relative w-full max-w-[340px] bg-white/[0.04] border border-white/[0.08] rounded-xl overflow-hidden hover:border-white/[0.16] transition-all group"> |
| {/* Preview */} |
| <div className="relative aspect-[4/5] bg-black/20 overflow-hidden"> |
| {item.latest?.preview_url ? (<img src={item.latest.preview_url} alt={item.name} loading="lazy" className="block w-full h-full object-cover" onError={(e) => { |
| ; |
| e.target.style.display = 'none'; |
| }}/>) : (<div className="w-full h-full flex items-center justify-center text-white/20"> |
| <User size={48}/> |
| </div>)} |
| {item.nsfw && (<span className="absolute top-2 right-2 px-2 py-0.5 rounded-full bg-red-500/80 text-white text-[10px] font-bold uppercase backdrop-blur-sm"> |
| NSFW |
| </span>)} |
| </div> |
| |
| {/* Body */} |
| <div className="p-3.5"> |
| <div className="text-sm font-semibold text-white truncate mb-1"> |
| {item.name} |
| </div> |
| <div className="text-xs text-white/40 line-clamp-2 mb-2.5 min-h-[32px]"> |
| {item.short} |
| </div> |
| |
| {/* Tags */} |
| <div className="flex items-center gap-1.5 mb-2.5 flex-wrap"> |
| {item.tags.slice(0, 3).map((t) => (<span key={t} className="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-300"> |
| {t} |
| </span>))} |
| </div> |
| |
| {/* Meta */} |
| <div className="flex items-center justify-between text-[11px] text-white/30 mb-3"> |
| <span>{(item.downloads || 0).toLocaleString()} downloads</span> |
| {sizeLabel && <span>{sizeLabel}</span>} |
| </div> |
| |
| {/* Action buttons */} |
| <div className="flex gap-2"> |
| <button onClick={onDetail} className="flex-1 py-2 rounded-lg text-xs font-semibold transition-all |
| bg-white/[0.06] hover:bg-white/[0.12] text-white/70 hover:text-white |
| border border-white/[0.08] |
| flex items-center justify-center gap-1.5"> |
| <Info size={13}/> |
| Details |
| </button> |
| <button onClick={onInstall} disabled={installing} className="flex-1 py-2 rounded-lg text-xs font-semibold transition-all |
| bg-purple-500/90 hover:bg-purple-500 text-white |
| disabled:opacity-50 disabled:cursor-not-allowed |
| flex items-center justify-center gap-1.5"> |
| {installing ? (<> |
| <Loader2 size={13} className="animate-spin"/> |
| Installing... |
| </>) : (<> |
| <Download size={13}/> |
| Install |
| </>)} |
| </button> |
| </div> |
| </div> |
| </div>); |
| } |
| |
| |
| |
| |
| |
| |
| function McpStatusBadge({ status }) { |
| const styles = { |
| available: 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300', |
| installable: 'bg-blue-500/15 border-blue-500/30 text-blue-300', |
| downloadable: 'bg-amber-500/15 border-amber-500/30 text-amber-300', |
| missing: 'bg-red-500/15 border-red-500/30 text-red-300', |
| degraded: 'bg-amber-500/15 border-amber-500/30 text-amber-300', |
| unknown: 'bg-white/10 border-white/20 text-white/50', |
| }; |
| const labels = { |
| available: 'Running', |
| installable: 'Will install', |
| downloadable: 'Will download & install', |
| missing: 'Missing', |
| degraded: 'Degraded', |
| unknown: 'Unknown', |
| }; |
| return (<span className={`text-[10px] px-2 py-0.5 rounded-full border ${styles[status] || styles.unknown}`}> |
| {labels[status] || status} |
| </span>); |
| } |
| function InstallPreviewModal({ state, onConfirm, onCancel, }) { |
| if (state.kind !== 'preview') |
| return null; |
| const { preview } = state; |
| const agent = preview.persona_agent || {}; |
| const depCheck = preview.dependency_check; |
| const allGood = depCheck?.all_satisfied !== false; |
| |
| const mcpServers = depCheck?.mcp_servers || []; |
| const needsMcpInstall = mcpServers.some((s) => s.status === 'installable' || s.status === 'downloadable'); |
| const hasMissing = mcpServers.some((s) => s.status === 'missing'); |
| return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> |
| <div className="bg-[#1a1a1c] border border-white/10 rounded-2xl max-w-md w-full mx-4 overflow-hidden shadow-2xl"> |
| {/* Header */} |
| <div className="p-5 border-b border-white/[0.06]"> |
| <div className="text-base font-semibold text-white"> |
| Install: {agent.label || 'Persona'} |
| </div> |
| <div className="text-xs text-white/40 mt-1"> |
| {agent.role || 'Community persona'} |
| </div> |
| </div> |
| |
| {/* Body */} |
| <div className="p-5 space-y-4 max-h-[50vh] overflow-y-auto"> |
| {/* System prompt preview */} |
| {agent.system_prompt && (<div> |
| <div className="text-xs font-medium text-white/50 mb-1.5">System Prompt</div> |
| <div className="text-xs text-white/60 bg-white/[0.03] border border-white/[0.06] rounded-lg p-3 max-h-24 overflow-y-auto leading-relaxed"> |
| {agent.system_prompt.slice(0, 300)} |
| {agent.system_prompt.length > 300 && '...'} |
| </div> |
| </div>)} |
| |
| {/* MCP Server Dependencies — detailed view */} |
| {mcpServers.length > 0 && (<div> |
| <div className="text-xs font-medium text-white/50 mb-1.5">MCP Servers</div> |
| <div className="space-y-2"> |
| {mcpServers.map((srv) => (<div key={srv.name} className="flex items-center justify-between bg-white/[0.03] border border-white/[0.06] rounded-lg px-3 py-2"> |
| <div className="flex-1 min-w-0"> |
| <div className="text-xs text-white/80 font-medium truncate">{srv.name}</div> |
| {srv.description && (<div className="text-[10px] text-white/40 truncate">{srv.description}</div>)} |
| </div> |
| <McpStatusBadge status={srv.status}/> |
| </div>))} |
| </div> |
| </div>)} |
| |
| {/* Overall dependency status */} |
| {depCheck && mcpServers.length === 0 && (<div> |
| <div className="text-xs font-medium text-white/50 mb-1.5">Dependencies</div> |
| <div className={`text-xs px-3 py-2 rounded-lg border ${allGood |
| ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' |
| : 'bg-amber-500/10 border-amber-500/20 text-amber-300'}`}> |
| {allGood |
| ? 'All dependencies satisfied' |
| : depCheck.summary || 'Some dependencies may need setup'} |
| </div> |
| </div>)} |
| |
| {/* MCP install notice */} |
| {needsMcpInstall && (<div className="text-xs px-3 py-2 rounded-lg border bg-blue-500/10 border-blue-500/20 text-blue-300"> |
| MCP servers will be automatically installed when you click Install. |
| </div>)} |
| |
| {/* Missing servers warning */} |
| {hasMissing && (<div className="text-xs px-3 py-2 rounded-lg border bg-red-500/10 border-red-500/20 text-red-300 flex items-start gap-2"> |
| <AlertTriangle size={14} className="mt-0.5 shrink-0"/> |
| <span>Some MCP servers cannot be auto-installed. The persona will work but may have limited functionality.</span> |
| </div>)} |
| |
| {/* Tools */} |
| {agent.allowed_tools && agent.allowed_tools.length > 0 && (<div> |
| <div className="text-xs font-medium text-white/50 mb-1.5">Tools</div> |
| <div className="flex flex-wrap gap-1"> |
| {agent.allowed_tools.map((t) => (<span key={t} className="text-[10px] px-2 py-0.5 rounded-full bg-white/[0.06] text-white/50"> |
| {t} |
| </span>))} |
| </div> |
| </div>)} |
| </div> |
| |
| {/* Footer */} |
| <div className="p-4 border-t border-white/[0.06] flex items-center justify-end gap-3"> |
| <button onClick={onCancel} className="px-4 py-2 text-sm text-white/60 hover:text-white transition-colors"> |
| Cancel |
| </button> |
| <button onClick={onConfirm} className="px-5 py-2 rounded-lg text-sm font-semibold bg-purple-500 hover:bg-purple-600 text-white transition-colors flex items-center gap-2"> |
| <Download size={14}/> |
| {needsMcpInstall ? 'Install Persona + Servers' : 'Install Persona'} |
| </button> |
| </div> |
| </div> |
| </div>); |
| } |
| |
| |
| |
| function DetailModal({ card, item, previewUrl, onClose, onInstall, }) { |
| const stats = card.stats || {}; |
| const statEntries = Object.entries(stats).filter(([k]) => k !== 'level'); |
| const level = stats.level ?? 0; |
| return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}> |
| <div className="bg-[#1a1a1c] border border-white/10 rounded-2xl max-w-lg w-full mx-4 overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}> |
| {/* Header with preview */} |
| <div className="relative"> |
| {previewUrl ? (<img src={previewUrl} alt={card.name || item.name} className="w-full h-48 object-cover" onError={(e) => { e.target.style.display = 'none'; }}/>) : (<div className="w-full h-32 bg-gradient-to-br from-purple-900/40 to-black/40 flex items-center justify-center"> |
| <User size={48} className="text-white/20"/> |
| </div>)} |
| <button onClick={onClose} className="absolute top-3 right-3 p-1.5 rounded-full bg-black/50 text-white/70 hover:text-white backdrop-blur-sm transition-colors"> |
| <X size={16}/> |
| </button> |
| {level > 0 && (<div className="absolute bottom-3 left-3 px-2.5 py-1 rounded-full bg-purple-500/80 text-white text-[10px] font-bold backdrop-blur-sm"> |
| Lv. {level} |
| </div>)} |
| </div> |
| |
| {/* Body */} |
| <div className="p-5 space-y-4 max-h-[50vh] overflow-y-auto"> |
| {/* Name & Role */} |
| <div> |
| <h2 className="text-lg font-bold text-white">{card.name || item.name}</h2> |
| <p className="text-sm text-white/50">{card.role || ''}</p> |
| </div> |
| |
| {/* Short description */} |
| {card.short && (<p className="text-sm text-white/60 leading-relaxed">{card.short}</p>)} |
| |
| {/* Backstory */} |
| {card.backstory && (<div> |
| <div className="flex items-center gap-1.5 text-xs font-medium text-white/50 mb-1.5"> |
| <BookOpen size={12}/> |
| Backstory |
| </div> |
| <p className="text-xs text-white/50 bg-white/[0.03] border border-white/[0.06] rounded-lg p-3 leading-relaxed"> |
| {card.backstory} |
| </p> |
| </div>)} |
| |
| {/* Stats bars */} |
| {statEntries.length > 0 && (<div> |
| <div className="flex items-center gap-1.5 text-xs font-medium text-white/50 mb-2"> |
| <Sparkles size={12}/> |
| Stats |
| </div> |
| <div className="space-y-1.5"> |
| {statEntries.map(([key, val]) => (<div key={key} className="flex items-center gap-2"> |
| <span className="text-[10px] text-white/40 w-20 capitalize">{key}</span> |
| <div className="flex-1 h-1.5 bg-white/[0.06] rounded-full overflow-hidden"> |
| <div className="h-full bg-purple-500/70 rounded-full transition-all" style={{ width: `${Math.min(100, val)}%` }}/> |
| </div> |
| <span className="text-[10px] text-white/30 w-6 text-right">{val}</span> |
| </div>))} |
| </div> |
| </div>)} |
| |
| {/* Style & Tone tags */} |
| {(card.style_tags?.length > 0 || card.tone_tags?.length > 0) && (<div className="flex flex-wrap gap-1.5"> |
| {(card.style_tags || []).map((t) => (<span key={`s-${t}`} className="text-[10px] px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-300"> |
| {t} |
| </span>))} |
| {(card.tone_tags || []).map((t) => (<span key={`t-${t}`} className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-300"> |
| {t} |
| </span>))} |
| </div>)} |
| |
| {/* Tools */} |
| {card.tools?.length > 0 && (<div> |
| <div className="flex items-center gap-1.5 text-xs font-medium text-white/50 mb-1.5"> |
| <Wrench size={12}/> |
| Tools |
| </div> |
| <div className="flex flex-wrap gap-1.5"> |
| {card.tools.map((t) => (<span key={t} className="text-[10px] px-2 py-0.5 rounded-full bg-white/[0.06] text-white/50"> |
| {t} |
| </span>))} |
| </div> |
| </div>)} |
| </div> |
| |
| {/* Footer */} |
| <div className="p-4 border-t border-white/[0.06] flex items-center justify-between"> |
| <div className="text-xs text-white/30"> |
| {(item.downloads || 0).toLocaleString()} downloads |
| </div> |
| <div className="flex gap-3"> |
| <button onClick={onClose} className="px-4 py-2 text-sm text-white/60 hover:text-white transition-colors"> |
| Close |
| </button> |
| <button onClick={() => { onClose(); onInstall(); }} className="px-5 py-2 rounded-lg text-sm font-semibold bg-purple-500 hover:bg-purple-600 text-white transition-colors flex items-center gap-2"> |
| <Download size={14}/> |
| Install |
| </button> |
| </div> |
| </div> |
| </div> |
| </div>); |
| } |
| |
| |
| |
| function InstallToast({ state, onDismiss, }) { |
| if (state.kind === 'done') { |
| return (<div className="fixed bottom-6 right-6 z-50 bg-emerald-600/90 text-white px-5 py-3 rounded-xl shadow-lg backdrop-blur-sm flex items-center gap-3 animate-in slide-in-from-bottom-4"> |
| <Package size={18}/> |
| <div> |
| <div className="text-sm font-semibold">Installed!</div> |
| <div className="text-xs opacity-80">{state.projectName} is ready in My Projects.</div> |
| </div> |
| <button onClick={onDismiss} className="ml-3 text-white/60 hover:text-white">×</button> |
| </div>); |
| } |
| if (state.kind === 'error') { |
| return (<div className="fixed bottom-6 right-6 z-50 bg-red-600/90 text-white px-5 py-3 rounded-xl shadow-lg backdrop-blur-sm flex items-center gap-3"> |
| <AlertTriangle size={18}/> |
| <div> |
| <div className="text-sm font-semibold">Install failed</div> |
| <div className="text-xs opacity-80">{state.message}</div> |
| </div> |
| <button onClick={onDismiss} className="ml-3 text-white/60 hover:text-white">×</button> |
| </div>); |
| } |
| return null; |
| } |
| |
| |
| |
| const PAGE_SIZE = 48; |
| export function CommunityGallery({ backendUrl, apiKey, onInstalled }) { |
| const [gallery, setGallery] = useState({ kind: 'loading' }); |
| const [search, setSearch] = useState(''); |
| const [tagFilter, setTagFilter] = useState(''); |
| const [install, setInstall] = useState({ kind: 'idle' }); |
| const [detail, setDetail] = useState(null); |
| const [detailLoading, setDetailLoading] = useState(null); |
| const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); |
| const cleanUrl = backendUrl.replace(/\/+$/, ''); |
| |
| const loadRegistry = useCallback(async () => { |
| setGallery({ kind: 'loading' }); |
| try { |
| const status = await communityStatus({ backendUrl: cleanUrl, apiKey }); |
| if (!status.configured) { |
| setGallery({ kind: 'not_configured' }); |
| return; |
| } |
| if (status.reachable === false) { |
| setGallery({ kind: 'unreachable' }); |
| return; |
| } |
| const data = await communityRegistry({ |
| backendUrl: cleanUrl, |
| apiKey, |
| search: search || undefined, |
| tag: tagFilter || undefined, |
| }); |
| setGallery({ |
| kind: 'loaded', |
| items: data.items, |
| total: data.total, |
| }); |
| } |
| catch (e) { |
| setGallery({ kind: 'error', message: e.message || 'Unknown error' }); |
| } |
| }, [cleanUrl, apiKey, search, tagFilter]); |
| useEffect(() => { |
| loadRegistry(); |
| }, [loadRegistry]); |
| |
| const allTags = useMemo(() => { |
| if (gallery.kind !== 'loaded') |
| return []; |
| const tags = new Set(); |
| for (const item of gallery.items) { |
| for (const t of item.tags) |
| tags.add(t); |
| } |
| return [...tags].sort(); |
| }, [gallery]); |
| |
| const handleInstall = useCallback(async (item) => { |
| const personaId = item.id; |
| const version = item.latest?.version; |
| if (!version) |
| return; |
| try { |
| |
| setInstall({ kind: 'downloading', personaId }); |
| const file = await communityDownloadPackage({ |
| backendUrl: cleanUrl, |
| apiKey, |
| personaId, |
| version, |
| }); |
| |
| setInstall({ kind: 'previewing', personaId }); |
| const preview = await previewPersonaPackage({ |
| backendUrl: cleanUrl, |
| apiKey, |
| file, |
| }); |
| |
| setInstall({ kind: 'preview', personaId, file, preview }); |
| } |
| catch (e) { |
| setInstall({ kind: 'error', personaId, message: e.message || 'Download failed' }); |
| } |
| }, [cleanUrl, apiKey]); |
| const handleConfirmInstall = useCallback(async () => { |
| if (install.kind !== 'preview') |
| return; |
| const { personaId, file, preview } = install; |
| |
| const mcpServers = preview.dependency_check?.mcp_servers || []; |
| const needsMcpInstall = mcpServers.some((s) => s.status === 'installable' || s.status === 'downloadable'); |
| try { |
| setInstall({ kind: 'installing', personaId }); |
| let projectName = 'Persona'; |
| if (needsMcpInstall) { |
| |
| const result = await importPersonaAtomic({ |
| backendUrl: cleanUrl, |
| apiKey, |
| file, |
| autoInstallServers: true, |
| }); |
| projectName = result.project?.name || projectName; |
| } |
| else { |
| |
| const result = await importPersonaPackage({ |
| backendUrl: cleanUrl, |
| apiKey, |
| file, |
| }); |
| projectName = result.project?.name || projectName; |
| } |
| setInstall({ kind: 'done', personaId, projectName }); |
| onInstalled?.(); |
| } |
| catch (e) { |
| setInstall({ kind: 'error', personaId, message: e.message || 'Import failed' }); |
| } |
| }, [install, cleanUrl, apiKey, onInstalled]); |
| const dismissInstall = useCallback(() => { |
| setInstall({ kind: 'idle' }); |
| }, []); |
| |
| const handleDetail = useCallback(async (item) => { |
| const version = item.latest?.version; |
| if (!version) |
| return; |
| setDetailLoading(item.id); |
| try { |
| const card = await communityCard({ |
| backendUrl: cleanUrl, |
| apiKey, |
| personaId: item.id, |
| version, |
| }); |
| setDetail({ item, card }); |
| } |
| catch { |
| |
| setDetail({ |
| item, |
| card: { name: item.name, short: item.short, tags: item.tags }, |
| }); |
| } |
| finally { |
| setDetailLoading(null); |
| } |
| }, [cleanUrl, apiKey]); |
| |
| |
| |
| |
| if (gallery.kind === 'loading') { |
| return (<div className="flex flex-col items-center justify-center h-64 text-white/50"> |
| <Loader2 size={32} className="animate-spin mb-4 opacity-50"/> |
| <p className="text-sm">Loading community gallery...</p> |
| </div>); |
| } |
| |
| if (gallery.kind === 'not_configured') { |
| return (<div className="flex flex-col items-center justify-center h-64 text-white/50"> |
| <Globe size={40} className="mb-4 opacity-40"/> |
| <p className="text-base font-semibold text-white/60 mb-2">Community Gallery</p> |
| <p className="text-sm text-white/40 text-center max-w-md mb-4"> |
| Browse and install community-created personas. The gallery was explicitly disabled. |
| Remove{' '} |
| <code className="text-xs bg-white/10 px-1.5 py-0.5 rounded">COMMUNITY_GALLERY_URL</code>{' '} |
| from your <code className="text-xs bg-white/10 px-1.5 py-0.5 rounded">.env</code>{' '} |
| to restore the default gallery, or set a custom URL. |
| </p> |
| <a href="https://github.com/ruslanmv/HomePilot/blob/main/docs/COMMUNITY_GALLERY.md" target="_blank" rel="noopener" className="text-xs text-purple-400 hover:text-purple-300 transition-colors"> |
| Learn how to set up the gallery |
| </a> |
| </div>); |
| } |
| |
| if (gallery.kind === 'unreachable') { |
| return (<div className="flex flex-col items-center justify-center h-64 text-white/50"> |
| <AlertTriangle size={40} className="mb-4 text-amber-400/60"/> |
| <p className="text-base font-semibold text-white/60 mb-2">Gallery Unavailable</p> |
| <p className="text-sm text-white/40 mb-4">The community gallery is configured but not reachable.</p> |
| <button onClick={loadRegistry} className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 text-sm transition-colors"> |
| <RefreshCw size={14}/> Retry |
| </button> |
| </div>); |
| } |
| |
| if (gallery.kind === 'error') { |
| return (<div className="flex flex-col items-center justify-center h-64 text-white/50"> |
| <AlertTriangle size={40} className="mb-4 text-red-400/60"/> |
| <p className="text-sm mb-4">{gallery.message}</p> |
| <button onClick={loadRegistry} className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 text-sm transition-colors"> |
| <RefreshCw size={14}/> Retry |
| </button> |
| </div>); |
| } |
| |
| const { items, total } = gallery; |
| return (<> |
| {/* Search & filter bar */} |
| <div className="flex items-center gap-3 mb-5 flex-wrap"> |
| <div className="relative flex-1 min-w-[200px] max-w-sm"> |
| <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-white/30"/> |
| <input type="text" value={search} onChange={(e) => { setSearch(e.target.value); setVisibleCount(PAGE_SIZE); }} placeholder="Search personas..." className="w-full pl-9 pr-4 py-2 rounded-lg bg-white/[0.06] border border-white/[0.08] text-sm text-white placeholder:text-white/30 outline-none focus:border-purple-500/50 transition-colors"/> |
| </div> |
| |
| {allTags.length > 0 && (<select value={tagFilter} onChange={(e) => { setTagFilter(e.target.value); setVisibleCount(PAGE_SIZE); }} className="px-3 py-2 rounded-lg bg-white/[0.06] border border-white/[0.08] text-sm text-white/70 outline-none focus:border-purple-500/50 transition-colors"> |
| <option value="" className="bg-white text-gray-900">All Tags</option> |
| {allTags.map((t) => (<option key={t} value={t} className="bg-white text-gray-900">{t}</option>))} |
| </select>)} |
| |
| <button onClick={loadRegistry} className="p-2 rounded-lg bg-white/[0.06] border border-white/[0.08] text-white/40 hover:text-white hover:bg-white/10 transition-colors" title="Refresh"> |
| <RefreshCw size={16}/> |
| </button> |
| |
| <span className="text-xs text-white/30 ml-auto"> |
| {items.length === total |
| ? `${total} personas` |
| : `${items.length} of ${total} personas`} |
| </span> |
| </div> |
| |
| {/* Grid */} |
| {items.length === 0 ? (<div className="flex flex-col items-center justify-center h-48 text-white/50"> |
| <Package size={36} className="mb-3 opacity-40"/> |
| <p className="text-sm">No personas found.</p> |
| {(search || tagFilter) && (<button onClick={() => { setSearch(''); setTagFilter(''); }} className="mt-2 text-xs text-purple-400 hover:text-purple-300"> |
| Clear filters |
| </button>)} |
| </div>) : (<> |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 justify-items-center"> |
| {items.slice(0, visibleCount).map((item) => (<GalleryCard key={item.id} item={item} installing={(install.kind === 'downloading' || install.kind === 'previewing' || install.kind === 'installing') |
| && install.personaId === item.id} onInstall={() => handleInstall(item)} onDetail={() => handleDetail(item)}/>))} |
| </div> |
| {visibleCount < items.length && (<div className="flex justify-center pt-6 pb-2"> |
| <button onClick={() => setVisibleCount((v) => v + PAGE_SIZE)} className="px-7 py-2.5 rounded-full border border-white/[0.08] bg-white/[0.04] text-white/50 text-sm font-semibold hover:border-purple-500/40 hover:text-purple-300 transition-all"> |
| Load more ({items.length - visibleCount} remaining) |
| </button> |
| </div>)} |
| </>)} |
|
|
| {} |
| {detail && (<DetailModal card={detail.card} item={detail.item} previewUrl={detail.item.latest?.preview_url} onClose={() => setDetail(null)} onInstall={() => handleInstall(detail.item)}/>)} |
|
|
| {} |
| <InstallPreviewModal state={install} onConfirm={handleConfirmInstall} onCancel={dismissInstall}/> |
|
|
| {} |
| <InstallToast state={install} onDismiss={dismissInstall}/> |
| </>); |
| } |
| export default CommunityGallery; |
|
|