'use client'; import { useEffect, useState, useCallback } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, ArrowUpCircle, Download, Loader2, Puzzle, SwatchBook, Star, Trash2, Check, Settings as SettingsIcon, ExternalLink, Shield, AlertTriangle, FileCode, ChevronDown, ChevronUp, } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; interface PreviewData { extension: { slug: string; name: string; type: 'plugin' | 'theme'; pluginType: string | null; description: string; longDescription: string | null; tags: string[]; permissions: string[]; totalDownloads: number; featured: boolean; githubRepo: string | null; license: string | null; minAppVersion: string | null; iconUrl: string | null; bannerUrl: string | null; author: { displayName: string; githubLogin: string; avatarUrl: string | null; verified?: boolean; } | null; latestVersion: string | null; versions: Array<{ version: string; changelog: string | null; bundleSize: number; minAppVersion: string | null; publishedAt: string | null; permissions: string[]; }>; screenshots: Array<{ url: string; altText: string | null }>; themePreviews: Array<{ variant: 'light' | 'dark'; previewPath: string; colors: Record | null; }>; createdAt: string | null; updatedAt: string | null; }; bundle: { manifest: Record | null; source: { name: string; content: string; truncated: boolean } | null; size: number; error: string | null; }; installed: boolean; installedVersion: string | null; } const RISKY_PERMISSIONS = new Set([ 'mail:write', 'mail:delete', 'storage:write', 'network', 'admin', ]); export default function MarketplacePreviewPage() { const params = useParams(); const slug = params.slug as string; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); const [uninstalling, setUninstalling] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [showSource, setShowSource] = useState(false); const [showManifest, setShowManifest] = useState(false); const fetchPreview = useCallback(async () => { setLoading(true); setError(null); try { const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`); if (!res.ok) { const body = await res.json().catch(() => ({})); setError(body.error || 'Failed to load preview'); return; } setData(await res.json()); } catch { setError('Failed to connect to extension directory'); } finally { setLoading(false); } }, [slug]); useEffect(() => { fetchPreview(); }, [fetchPreview]); async function handleInstall() { if (!data) return; const isUpdate = data.installed; const targetVersion = data.extension.latestVersion || '1.0.0'; setInstalling(true); setMessage(null); try { const res = await apiFetch('/api/admin/marketplace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: data.extension.slug, version: targetVersion, type: data.extension.type, }), }); const body = await res.json(); if (res.ok) { const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; setMessage({ type: 'success', text: isUpdate ? `"${data.extension.name}" updated to v${targetVersion}${warnings}` : `"${data.extension.name}" installed${warnings}`, }); setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev); } else { setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') }); } } catch { setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' }); } finally { setInstalling(false); } } async function handleUninstall() { if (!data) return; if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return; setUninstalling(true); setMessage(null); try { const endpoint = data.extension.type === 'theme' ? '/api/admin/themes' : '/api/admin/plugins'; const res = await apiFetch(endpoint, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: data.extension.slug }), }); const body = await res.json().catch(() => ({})); if (res.ok) { setMessage({ type: 'success', text: `"${data.extension.name}" removed` }); setData(prev => prev ? { ...prev, installed: false } : prev); } else { setMessage({ type: 'error', text: body.error || 'Uninstall failed' }); } } catch { setMessage({ type: 'error', text: 'Uninstall failed - network error' }); } finally { setUninstalling(false); } } if (loading) { return (
Loading...
); } if (error || !data) { return (
Back to Marketplace

{error || 'Extension not found'}

); } const ext = data.extension; const bundle = data.bundle; const isPlugin = ext.type === 'plugin'; const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || []; const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined; const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion); const updateAvailable = data.installed && !!data.installedVersion && !!ext.latestVersion && compareVersions(ext.latestVersion, data.installedVersion) > 0 && !versionMismatch; return (
{/* Back link */} Back to Marketplace {/* Banner / hero */} {ext.bannerUrl && (
)} {/* Header */}
{ext.iconUrl ? ( ) : isPlugin ? ( ) : ( )}

{ext.name}

{ext.featured && } {data.installed && !updateAvailable && ( Installed )} {data.installed && updateAvailable && ( Update available )}
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'} {ext.author && ( by {ext.author.displayName} )} {ext.latestVersion && v{ext.latestVersion}} {ext.license && {ext.license}} {ext.totalDownloads.toLocaleString()}
{/* Action buttons */}
{data.installed ? ( <> {updateAvailable && ( )} Manage ) : ( )}
{message && (
{message.text}
)} {versionMismatch && (

Update Bulwark to install this extension

Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.

)} {bundle.error && (

Could not preview bundle

{bundle.error}

)} {/* Description */}

About

{ext.description}

{ext.longDescription && ext.longDescription !== ext.description && (

{ext.longDescription}

)} {ext.tags.length > 0 && (
{ext.tags.map(tag => ( {tag} ))}
)}
{ext.minAppVersion && Requires app v{ext.minAppVersion}+} {bundle.size > 0 && Bundle: {(bundle.size / 1024).toFixed(1)} KB} {ext.githubRepo && ( {ext.githubRepo} )}
{/* Screenshots */} {ext.screenshots.length > 0 && (

Screenshots

{ext.screenshots.map((s, i) => ( {s.altText ))}
)} {/* Theme color preview */} {!isPlugin && ext.themePreviews.length > 0 && (

Theme preview

{ext.themePreviews.map(preview => ( ))}
)} {/* Permissions */} {isPlugin && (

Permissions

{manifestPerms.length === 0 ? (

This plugin requests no permissions.

) : (
    {manifestPerms.map(perm => { const risky = RISKY_PERMISSIONS.has(perm); return (
  • {risky && } {perm}
  • ); })}
)} {frameOrigins.length > 0 && (

Iframe origins

The plugin will be allowed to embed content from these origins.

    {frameOrigins.map(origin => (
  • {origin}
  • ))}
)}
)} {/* Settings schema preview */} {isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (

User settings

Settings users will be able to configure after install.

    {Object.entries(settingsSchema).map(([key, field]) => (
  • {key} {field.type}
    {field.label}
    {field.description && (
    {field.description}
    )}
  • ))}
)} {/* Source / manifest disclosure */} {bundle.manifest && (
{showManifest && (
              {JSON.stringify(bundle.manifest, null, 2)}
            
)}
)} {bundle.source && (
{showSource && (
              {bundle.source.content}
            
)}
)} {/* Version history */} {ext.versions.length > 0 && (

Version history

    {ext.versions.slice(0, 5).map(v => (
  • v{v.version} {v.publishedAt && ( {new Date(v.publishedAt).toLocaleDateString()} )}
    {v.changelog && (

    {v.changelog}

    )}
    {(v.bundleSize / 1024).toFixed(1)} KB
  • ))}
)}
); } function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record | null } }) { const colors = preview.colors || {}; const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff'); const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a'); const accent = colors.primary || colors.accent || '#7c5cff'; const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5'); const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5'); return (
{preview.variant}
Sample text
Card surface
{Object.entries(colors).slice(0, 6).map(([key, value]) => ( ))}
); }