feat: add plugin/theme harness and admin dashboard

Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
Linus Rath
2026-03-25 00:44:03 +01:00
parent 78bcf8db1b
commit 76b21147e4
63 changed files with 7894 additions and 67 deletions
+2
View File
@@ -18,6 +18,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
@@ -1237,6 +1238,7 @@ export function EmailComposer({
</Button>
</>
)}
<PluginSlot name="composer-toolbar" />
</div>
{/* Right side - Discard + Send (desktop) */}
+3
View File
@@ -9,6 +9,7 @@ import {
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
Reply,
ReplyAll,
@@ -366,6 +367,8 @@ export function EmailContextMenu({
)
}
/>
<PluginSlot name="context-menu-email" />
</ContextMenu>
);
}
+6
View File
@@ -95,6 +95,7 @@ import type { SmimeStatus } from "@/lib/smime/types";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
interface EmailViewerProps {
email: Email | null;
@@ -2819,6 +2820,7 @@ export function EmailViewer({
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
</div>
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
@@ -4443,6 +4445,8 @@ export function EmailViewer({
<div>
<PluginSlot name="email-banner" />
{/* Email Body */}
<div className="email-content-wrapper overflow-x-auto">
{effectiveEmailContent.isHtml ? (
@@ -4470,6 +4474,8 @@ export function EmailViewer({
)}
</div>
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts */}
{!isDraft && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
+26 -3
View File
@@ -5,13 +5,17 @@ import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
interface NavItem {
id: string;
@@ -152,10 +156,14 @@ export function NavigationRail({
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
@@ -218,7 +226,7 @@ export function NavigationRail({
})}
{/* Custom sidebar apps (per-app mobile visibility) */}
{sidebarApps.filter((app) => app.showOnMobile).map((app) => {
{visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
const isActive = activeAppId === app.id;
return (
@@ -287,6 +295,19 @@ export function NavigationRail({
className
)}
>
{(() => {
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
return logoUrl ? (
<div className="flex items-center justify-center py-3 px-1">
<img
src={logoUrl}
alt=""
className="w-8 h-8 object-contain"
/>
</div>
) : null;
})()}
<nav
className={cn(
"flex flex-col",
@@ -333,7 +354,7 @@ export function NavigationRail({
})}
{/* Custom sidebar apps */}
{sidebarApps.length > 0 && (
{visibleSidebarApps.length > 0 && (
<div
className={cn(
"border-t",
@@ -342,7 +363,7 @@ export function NavigationRail({
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
/>
)}
{sidebarApps.map((app) => {
{visibleSidebarApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
const isActive = activeAppId === app.id;
return (
@@ -397,6 +418,8 @@ export function NavigationRail({
)}
</nav>
<PluginSlot name="navigation-rail-bottom" />
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
<Link
+3 -15
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Button } from "@/components/ui/button";
import {
Inbox,
@@ -41,8 +42,6 @@ import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/s
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { AccountSwitcher } from "./account-switcher";
import { useTour } from "@/components/tour/tour-provider";
@@ -431,8 +430,6 @@ export function Sidebar({
}: SidebarProps) {
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity } = useAuthStore();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [tagsExpanded, setTagsExpanded] = useState(() => {
try {
@@ -532,17 +529,6 @@ export function Sidebar({
<X className="w-5 h-5" />
</Button>
{(() => {
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
return logoUrl ? (
<img
src={logoUrl}
alt=""
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
/>
) : null;
})()}
<Button
variant="ghost"
size="icon"
@@ -673,6 +659,8 @@ export function Sidebar({
)}
</div>
<PluginSlot name="sidebar-widget" />
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
@@ -0,0 +1,36 @@
'use client';
import React from 'react';
export interface PluginErrorBoundaryProps {
pluginId: string;
children?: React.ReactNode;
fallback?: React.ReactNode;
}
interface PluginErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class PluginErrorBoundary extends React.Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
constructor(props: PluginErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error(`[plugin:${this.props.pluginId}] Render error:`, error, errorInfo);
}
render(): React.ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? null;
}
return this.props.children;
}
}
@@ -0,0 +1,21 @@
'use client';
import React from 'react';
import type { SlotRegistration } from '@/lib/plugin-types';
import { PluginErrorBoundary } from './plugin-error-boundary';
interface PluginSlotRendererProps {
registration: SlotRegistration;
fallback?: React.ReactNode;
extraProps?: Record<string, unknown>;
}
export function PluginSlotRenderer({ registration, fallback = null, extraProps }: PluginSlotRendererProps) {
const Component = registration.component;
return (
<PluginErrorBoundary pluginId={registration.pluginId} fallback={fallback}>
<Component {...(extraProps ?? {})} />
</PluginErrorBoundary>
);
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import React from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { PluginSlotRenderer } from './plugin-slot-renderer';
interface PluginSlotProps {
name: SlotName;
className?: string;
extraProps?: Record<string, unknown>;
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const registrations = usePluginStore(s => s.slots[name]);
if (!registrations || registrations.length === 0) return null;
return (
<div className={className} data-plugin-slot={name}>
{registrations.map((reg, i) => (
<PluginSlotRenderer
key={`${reg.pluginId}-${i}`}
registration={reg}
extraProps={extraProps}
/>
))}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
'use client';
import { useState, useRef } from 'react';
import { usePluginStore } from '@/stores/plugin-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { Upload, Trash2, AlertTriangle, Puzzle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/stores/toast-store';
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
const STATUS_COLORS: Record<PluginStatus, string> = {
installed: 'bg-muted text-muted-foreground',
enabled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
running: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
disabled: 'bg-muted text-muted-foreground',
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
const [isUploading, setIsUploading] = useState(false);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const result = await installPlugin(file);
if (result.success) {
toast.success('Plugin installed');
if (result.warnings?.length) {
toast.warning('Plugin warnings', { message: result.warnings.join('\n') });
}
} else {
toast.error('Plugin installation failed', { message: result.error });
}
} catch (err) {
toast.error('Plugin installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleToggle = async (plugin: InstalledPlugin) => {
if (plugin.enabled) {
disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`);
} else {
await enablePlugin(plugin.id);
toast.success(`Plugin "${plugin.name}" enabled`);
}
};
const handleUninstall = (plugin: InstalledPlugin) => {
uninstallPlugin(plugin.id);
toast.success(`Plugin "${plugin.name}" removed`);
};
return (
<SettingsSection title="Plugins" description="Manage installed plugins. Upload plugin .zip files to add new functionality.">
{/* Plugin List */}
{plugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground mb-1">No plugins installed</p>
<p className="text-xs text-muted-foreground/70">Upload a plugin .zip file to get started</p>
</div>
) : (
<div className="space-y-2">
{plugins.map(plugin => (
<PluginCard
key={plugin.id}
plugin={plugin}
isExpanded={expandedPlugin === plugin.id}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/>
))}
</div>
)}
{/* Upload */}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
className="hidden"
aria-label="Upload plugin file"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="w-4 h-4 mr-1.5" />
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
</SettingsSection>
);
}
// ─── Plugin Card ─────────────────────────────────────────────
interface PluginCardProps {
plugin: InstalledPlugin;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: () => void;
onUninstall: () => void;
onUpdateSettings: (settings: Record<string, unknown>) => void;
}
function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return (
<div className={cn(
'rounded-lg border transition-colors',
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
)}>
{/* Header */}
<div className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">{plugin.name}</span>
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', STATUS_COLORS[plugin.status])}>
{plugin.status}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{plugin.author}</span>
<span className="text-xs text-muted-foreground/50">v{plugin.version}</span>
<span className="text-xs text-muted-foreground/50">{plugin.type}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="border-t border-border p-3 space-y-3">
{/* Description */}
{plugin.description && (
<p className="text-xs text-muted-foreground">{plugin.description}</p>
)}
{/* Error */}
{plugin.error && (
<div className="flex items-start gap-2 p-2 rounded bg-destructive/10 text-destructive text-xs">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{plugin.error}</span>
</div>
)}
{/* Permissions */}
{plugin.permissions.length > 0 && (
<div>
<span className="text-xs font-medium text-foreground">Permissions:</span>
<div className="flex flex-wrap gap-1 mt-1">
{plugin.permissions.map(perm => (
<span key={perm} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{perm}
</span>
))}
</div>
</div>
)}
{/* Settings (auto-generated from schema) */}
{plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Settings:</span>
{Object.entries(plugin.settingsSchema).map(([key, schema]) => (
<PluginSettingField
key={key}
fieldKey={key}
schema={schema}
value={plugin.settings[key] ?? schema.default}
onChange={(value) => onUpdateSettings({ [key]: value })}
/>
))}
</div>
)}
{/* Uninstall */}
<div className="flex justify-end pt-2 border-t border-border">
<Button variant="destructive" size="sm" onClick={onUninstall}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Uninstall
</Button>
</div>
</div>
)}
</div>
);
}
// ─── Auto-generated Setting Field ────────────────────────────
interface PluginSettingFieldProps {
fieldKey: string;
schema: SettingFieldSchema;
value: unknown;
onChange: (value: unknown) => void;
}
function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps) {
switch (schema.type) {
case 'boolean':
return (
<div className="flex items-center justify-between">
<div>
<span className="text-xs text-foreground">{schema.label}</span>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<ToggleSwitch checked={value as boolean} onChange={(v) => onChange(v)} />
</div>
);
case 'select':
return (
<div className="flex items-center justify-between">
<div>
<span className="text-xs text-foreground">{schema.label}</span>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<select
value={String(value)}
onChange={(e) => onChange(e.target.value)}
className="text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
>
{schema.options?.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
);
case 'string':
return (
<div>
<span className="text-xs text-foreground">{schema.label}</span>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
<input
type="text"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
className="mt-1 w-full text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
/>
</div>
);
case 'number':
return (
<div className="flex items-center justify-between">
<div>
<span className="text-xs text-foreground">{schema.label}</span>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<input
type="number"
value={Number(value ?? schema.default ?? 0)}
min={schema.min}
max={schema.max}
onChange={(e) => onChange(Number(e.target.value))}
className="w-20 text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
/>
</div>
);
default:
return null;
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SettingsSectionProps {
@@ -25,18 +26,22 @@ interface SettingItemProps {
label: string;
description?: string;
children: ReactNode;
locked?: boolean;
}
export function SettingItem({ label, description, children }: SettingItemProps) {
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
return (
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
<div className="flex-1 pr-4">
<label className="text-sm font-medium text-foreground">{label}</label>
<div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="flex-shrink-0">{children}</div>
<div className={cn("flex-shrink-0", locked && "pointer-events-none")}>{children}</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useState, useRef } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection, SettingItem } from './settings-section';
import { cn } from '@/lib/utils';
import { Upload, Trash2, Check, Palette } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/stores/toast-store';
import type { InstalledTheme } from '@/lib/plugin-types';
export function ThemesSettings() {
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const result = await installTheme(file);
if (result.success) {
toast.success('Theme installed');
if (result.warnings?.length) {
toast.warning('Theme warnings', { message: result.warnings.join('\n') });
}
} else {
toast.error('Theme installation failed', { message: result.error });
}
} catch (err) {
toast.error('Theme installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
} finally {
setIsUploading(false);
// Reset file input
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleActivate = (id: string | null) => {
activateTheme(id);
toast.success(id ? 'Theme activated' : 'Default theme restored');
};
const handleUninstall = (theme: InstalledTheme) => {
if (theme.builtIn) return;
uninstallTheme(theme.id);
toast.success('Theme removed');
};
return (
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets.">
{/* Theme Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* Default theme card */}
<ThemeCard
name="Default"
author="Bulwark"
isActive={activeThemeId === null}
isBuiltIn
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{installedThemes.map(theme => (
<ThemeCard
key={theme.id}
name={theme.name}
author={theme.author}
preview={theme.preview}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
/>
))}
</div>
{/* Upload */}
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
className="hidden"
aria-label="Upload theme file"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="w-4 h-4 mr-1.5" />
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
</SettingsSection>
);
}
// ─── Theme Card ──────────────────────────────────────────────
interface ThemeCardProps {
name: string;
author: string;
preview?: string;
isActive: boolean;
isBuiltIn: boolean;
variants?: ('light' | 'dark')[];
onActivate: () => void;
onRemove?: () => void;
}
function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRemove }: ThemeCardProps) {
return (
<button
onClick={onActivate}
className={cn(
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card'
)}
>
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
)}
</div>
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
{isActive && <Check className="w-4 h-4 text-primary flex-shrink-0" />}
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
</div>
)}
</div>
{/* Remove button */}
{onRemove && !isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Remove theme"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</button>
);
}