/**
* 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 ? (
{
;
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 */}
Details
{installing ? (<>
Installing...
>) : (<>
Install
>)}
);
}
// ---------------------------------------------------------------------------
// 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 */}
Cancel
{needsMcpInstall ? 'Install Persona + Servers' : 'Install Persona'}
);
}
// ---------------------------------------------------------------------------
// 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 ? (
{ 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]) => (
))}
)}
{/* 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
Close
{ 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">
Install
);
}
// ---------------------------------------------------------------------------
// 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.
Retry
);
}
// Error
if (gallery.kind === 'error') {
return ();
}
// 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 && (
{ 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">
All Tags
{allTags.map((t) => ({t} ))}
)}
{items.length === total
? `${total} personas`
: `${items.length} of ${total} personas`}
{/* Grid */}
{items.length === 0 ? (
No personas found.
{(search || tagFilter) && (
{ setSearch(''); setTagFilter(''); }} className="mt-2 text-xs text-purple-400 hover:text-purple-300">
Clear filters
)}
) : (<>
{items.slice(0, visibleCount).map((item) => ( handleInstall(item)} onDetail={() => handleDetail(item)}/>))}
{visibleCount < items.length && (
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)
)}
>)}
{/* Detail modal */}
{detail && ( setDetail(null)} onInstall={() => handleInstall(detail.item)}/>)}
{/* Install preview modal */}
{/* Toast */}
>);
}
export default CommunityGallery;