/** * CommunityGallery — Phase 3 Community Gallery * * Embeddable gallery component that renders inside ProjectsView's * "Shared with me" tab. Fetches from the backend proxy (/community/registry), * shows persona cards with previews, search, tag filtering, and one-click * install via the existing PersonaImportModal flow. */ 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'; // --------------------------------------------------------------------------- // Gallery Card // --------------------------------------------------------------------------- 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 (
{/* Preview */}
{item.latest?.preview_url ? ({item.name} { ; e.target.style.display = 'none'; }}/>) : (
)} {item.nsfw && ( NSFW )}
{/* Body */}
{item.name}
{item.short}
{/* Tags */}
{item.tags.slice(0, 3).map((t) => ( {t} ))}
{/* Meta */}
{(item.downloads || 0).toLocaleString()} downloads {sizeLabel && {sizeLabel}}
{/* Action buttons */}
); } // --------------------------------------------------------------------------- // Install Preview Modal // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // MCP Dependency Status Badge // --------------------------------------------------------------------------- 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 ( {labels[status] || status} ); } 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; // Check if MCP servers need to be installed 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 (
{/* Header */}
Install: {agent.label || 'Persona'}
{agent.role || 'Community persona'}
{/* Body */}
{/* System prompt preview */} {agent.system_prompt && (
System Prompt
{agent.system_prompt.slice(0, 300)} {agent.system_prompt.length > 300 && '...'}
)} {/* MCP Server Dependencies — detailed view */} {mcpServers.length > 0 && (
MCP Servers
{mcpServers.map((srv) => (
{srv.name}
{srv.description && (
{srv.description}
)}
))}
)} {/* Overall dependency status */} {depCheck && mcpServers.length === 0 && (
Dependencies
{allGood ? 'All dependencies satisfied' : depCheck.summary || 'Some dependencies may need setup'}
)} {/* MCP install notice */} {needsMcpInstall && (
MCP servers will be automatically installed when you click Install.
)} {/* Missing servers warning */} {hasMissing && (
Some MCP servers cannot be auto-installed. The persona will work but may have limited functionality.
)} {/* Tools */} {agent.allowed_tools && agent.allowed_tools.length > 0 && (
Tools
{agent.allowed_tools.map((t) => ( {t} ))}
)}
{/* Footer */}
); } // --------------------------------------------------------------------------- // Detail Modal // --------------------------------------------------------------------------- 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 (
e.stopPropagation()}> {/* Header with preview */}
{previewUrl ? ({card.name { e.target.style.display = 'none'; }}/>) : (
)} {level > 0 && (
Lv. {level}
)}
{/* Body */}
{/* Name & Role */}

{card.name || item.name}

{card.role || ''}

{/* Short description */} {card.short && (

{card.short}

)} {/* Backstory */} {card.backstory && (
Backstory

{card.backstory}

)} {/* Stats bars */} {statEntries.length > 0 && (
Stats
{statEntries.map(([key, val]) => (
{key}
{val}
))}
)} {/* Style & Tone tags */} {(card.style_tags?.length > 0 || card.tone_tags?.length > 0) && (
{(card.style_tags || []).map((t) => ( {t} ))} {(card.tone_tags || []).map((t) => ( {t} ))}
)} {/* Tools */} {card.tools?.length > 0 && (
Tools
{card.tools.map((t) => ( {t} ))}
)}
{/* Footer */}
{(item.downloads || 0).toLocaleString()} downloads
); } // --------------------------------------------------------------------------- // Success / Error Toast // --------------------------------------------------------------------------- function InstallToast({ state, onDismiss, }) { if (state.kind === 'done') { return (
Installed!
{state.projectName} is ready in My Projects.
); } if (state.kind === 'error') { return (
Install failed
{state.message}
); } return null; } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- 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(/\/+$/, ''); // Fetch registry 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]); // Collect all unique tags 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]); // Install flow const handleInstall = useCallback(async (item) => { const personaId = item.id; const version = item.latest?.version; if (!version) return; try { // Step 1: Download setInstall({ kind: 'downloading', personaId }); const file = await communityDownloadPackage({ backendUrl: cleanUrl, apiKey, personaId, version, }); // Step 2: Preview setInstall({ kind: 'previewing', personaId }); const preview = await previewPersonaPackage({ backendUrl: cleanUrl, apiKey, file, }); // Step 3: Show preview modal 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; // Check if MCP servers need installation 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) { // Atomic install: install MCP servers + create persona in one call const result = await importPersonaAtomic({ backendUrl: cleanUrl, apiKey, file, autoInstallServers: true, }); projectName = result.project?.name || projectName; } else { // Simple install: just create the persona project 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' }); }, []); // Detail flow 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 { // Fallback: show what we have from the registry item setDetail({ item, card: { name: item.name, short: item.short, tags: item.tags }, }); } finally { setDetailLoading(null); } }, [cleanUrl, apiKey]); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- // Loading if (gallery.kind === 'loading') { return (

Loading community gallery...

); } // Not configured if (gallery.kind === 'not_configured') { return (

Community Gallery

Browse and install community-created personas. The gallery was explicitly disabled. Remove{' '} COMMUNITY_GALLERY_URL{' '} from your .env{' '} to restore the default gallery, or set a custom URL.

Learn how to set up the gallery
); } // Unreachable if (gallery.kind === 'unreachable') { return (

Gallery Unavailable

The community gallery is configured but not reachable.

); } // Error if (gallery.kind === 'error') { return (

{gallery.message}

); } // Loaded const { items, total } = gallery; return (<> {/* Search & filter bar */}
{ 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"/>
{allTags.length > 0 && ()} {items.length === total ? `${total} personas` : `${items.length} of ${total} personas`}
{/* Grid */} {items.length === 0 ? (

No personas found.

{(search || tagFilter) && ()}
) : (<>
{items.slice(0, visibleCount).map((item) => ( handleInstall(item)} onDetail={() => handleDetail(item)}/>))}
{visibleCount < items.length && (
)} )} {/* Detail modal */} {detail && ( setDetail(null)} onInstall={() => handleInstall(detail.item)}/>)} {/* Install preview modal */} {/* Toast */} ); } export default CommunityGallery;