HomePilot / frontend /src /ui /CommunityGallery.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
28.7 kB
/**
* 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 (<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>);
}
// ---------------------------------------------------------------------------
// 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 (<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;
// 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 (<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>);
}
// ---------------------------------------------------------------------------
// 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 (<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>);
}
// ---------------------------------------------------------------------------
// Success / Error Toast
// ---------------------------------------------------------------------------
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">&times;</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">&times;</button>
</div>);
}
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 (<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>);
}
// Not configured
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>);
}
// Unreachable
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>);
}
// Error
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>);
}
// Loaded
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 modal */}
{detail && (<DetailModal card={detail.card} item={detail.item} previewUrl={detail.item.latest?.preview_url} onClose={() => setDetail(null)} onInstall={() => handleInstall(detail.item)}/>)}
{/* Install preview modal */}
<InstallPreviewModal state={install} onConfirm={handleConfirmInstall} onCancel={dismissInstall}/>
{/* Toast */}
<InstallToast state={install} onDismiss={dismissInstall}/>
</>);
}
export default CommunityGallery;