mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(plugins): richer admin panel + registry detail view
Admin list: flush-left header like Addons, type/reviewed badges, runtime health as a dot on the icon tile (text badge only for problem states), description + source-repo link on installed cards, manifest icon. Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id — live-manifest permissions in plain language, egress hosts, setup preview, repo/homepage links. Manifest fetched server-side through safeDownload at the reviewed commit, cached per plugin for 30 min and only when a detail opens. Also fixes the update flow (restart the running child around the install, keep the admin's enabled intent instead of force-activating disabled plugins), guards the icon lookup against Object.prototype names, reserves ids that would shadow static admin routes, stops negative-caching failed manifest fetches, and makes the version compare prerelease-safe.
This commit is contained in:
@@ -462,6 +462,7 @@ export const adminApi = {
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
|
||||
pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, ArrowUpCircle, Puzzle } from 'lucide-react'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronRight, Check, Luggage, Plane, Globe, Image,
|
||||
CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle,
|
||||
} from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -8,8 +12,10 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). Separates the admin's ON/OFF intent (the toggle,
|
||||
* backed by `enabled`) from runtime health (`status`), surfaces per-plugin errors
|
||||
* and available updates, and hosts the registry browser. Gated by the
|
||||
* backed by `enabled`) from runtime health (`status`, the coloured dot on the
|
||||
* icon tile), surfaces per-plugin errors and available updates, and hosts the
|
||||
* registry browser with a per-plugin detail dialog (screenshot, permissions,
|
||||
* setup preview — fetched live at the reviewed commit). Gated by the
|
||||
* runtime-enabled flag.
|
||||
*/
|
||||
|
||||
@@ -18,45 +24,109 @@ interface PluginRow {
|
||||
name: string
|
||||
description: string | null
|
||||
type: string
|
||||
icon: string | null
|
||||
version: string | null
|
||||
status: string
|
||||
enabled: number
|
||||
last_error: string | null
|
||||
reviewed_at: string | null
|
||||
source_repo: string | null
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
name: string
|
||||
author: string
|
||||
description: string
|
||||
repo: string
|
||||
homepage?: string | null
|
||||
type: string
|
||||
latest: string | null
|
||||
minTrekVersion: string | null
|
||||
reviewedAt: string | null
|
||||
screenshotUrl: string | null
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
manifest: {
|
||||
permissions: string[]
|
||||
egress: string[]
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
license: string | null
|
||||
icon: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
// Runtime health → colour + dot. `enabled` is shown separately by the toggle.
|
||||
const HEALTH: Record<string, { cls: string; dot: string }> = {
|
||||
active: { cls: 'text-emerald-600', dot: 'bg-emerald-500' },
|
||||
starting: { cls: 'text-sky-600', dot: 'bg-sky-500 animate-pulse' },
|
||||
error: { cls: 'text-rose-600', dot: 'bg-rose-500' },
|
||||
inactive: { cls: 'text-content-faint', dot: 'bg-content-faint/50' },
|
||||
disabled: { cls: 'text-amber-600', dot: 'bg-amber-500' },
|
||||
incompatible: { cls: 'text-orange-600', dot: 'bg-orange-500' },
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
// Runtime health → dot colour on the icon tile. `enabled` is shown by the toggle.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-emerald-500',
|
||||
starting: 'bg-sky-500 animate-pulse',
|
||||
error: 'bg-rose-500',
|
||||
inactive: 'bg-content-faint/50',
|
||||
disabled: 'bg-amber-500',
|
||||
incompatible: 'bg-orange-500',
|
||||
}
|
||||
// States worth calling out with a text badge (ok states speak through the dot).
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
starting: 'bg-sky-500/10 text-sky-600',
|
||||
error: 'bg-rose-500/10 text-rose-600',
|
||||
disabled: 'bg-amber-500/10 text-amber-600',
|
||||
incompatible: 'bg-orange-500/10 text-orange-600',
|
||||
}
|
||||
|
||||
// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks,
|
||||
}
|
||||
|
||||
// Known permissions → human-readable i18n key; unknown ones render as raw code.
|
||||
const PERM_KEYS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||||
]
|
||||
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
|
||||
const pa = nums(a)
|
||||
const pb = nums(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = pa[i] || 0
|
||||
const y = pb[i] || 0
|
||||
if (x !== y) return x > y
|
||||
}
|
||||
return false
|
||||
// Same numeric triple: the stable release is newer than its prerelease.
|
||||
return !a.includes('-') && b.includes('-')
|
||||
}
|
||||
|
||||
function PluginIcon({ name, size = 18, className }: { name: string | null; size?: number; className?: string }) {
|
||||
// hasOwnProperty guard: the icon name is remote manifest data, and a plain
|
||||
// object lookup would otherwise reach Object.prototype ("constructor", …).
|
||||
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
|
||||
return <Icon size={size} className={className} />
|
||||
}
|
||||
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration']
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-accent/10 text-accent">
|
||||
{KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-600">
|
||||
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPluginsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
@@ -66,6 +136,7 @@ export default function AdminPluginsPanel() {
|
||||
const [view, setView] = useState<'installed' | 'browse'>('installed')
|
||||
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
|
||||
const [latest, setLatest] = useState<Record<string, string>>({})
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
|
||||
@@ -92,9 +163,10 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(id)
|
||||
try { await fn(); toast.success(ok); refresh() }
|
||||
try { await fn(); toast.success(ok) }
|
||||
catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) }
|
||||
finally { setBusy(null) }
|
||||
// Refresh even on failure: a multi-step action may have partially committed.
|
||||
finally { setBusy(null); refresh() }
|
||||
}
|
||||
|
||||
const openBrowse = () => {
|
||||
@@ -107,23 +179,22 @@ export default function AdminPluginsPanel() {
|
||||
.catch(() => setErrorsFor({ id, rows: [] }))
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))
|
||||
const installedIds = new Set(plugins.map(p => p.id))
|
||||
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-edge-secondary flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl grid place-items-center bg-accent/10 text-accent">
|
||||
<Puzzle size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t('admin.plugins.subtitle')}</p>
|
||||
{/* Header — flush-left title/subtitle, like the Addons panel */}
|
||||
<div className="px-6 py-4 border-b border-edge-secondary flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))}
|
||||
className="flex items-center gap-1.5 text-xs font-medium px-3 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<RefreshCw size={14} /> {t('admin.plugins.rescan')}
|
||||
<RefreshCw size={14} /> <span className="hidden sm:inline">{t('admin.plugins.rescan')}</span>
|
||||
</button>
|
||||
<button onClick={view === 'browse' ? () => setView('installed') : openBrowse}
|
||||
className="flex items-center gap-1.5 text-xs font-semibold px-3.5 py-2 rounded-lg bg-accent text-accent-text hover:opacity-90 transition-opacity">
|
||||
@@ -150,8 +221,8 @@ export default function AdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-10 text-center text-sm text-rose-600">{t('admin.plugins.loadError')}</div>
|
||||
) : view === 'browse' ? (
|
||||
<RegistryGrid items={registry} busy={busy} t={t} installedIds={new Set(plugins.map(p => p.id))}
|
||||
onInstall={(id) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))} />
|
||||
<RegistryGrid items={registry} busy={busy} t={t} installedIds={installedIds}
|
||||
onInstall={install} onOpenDetail={setDetailFor} />
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="py-14 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-tertiary grid place-items-center mx-auto mb-4">
|
||||
@@ -167,37 +238,55 @@ export default function AdminPluginsPanel() {
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{plugins.map(p => {
|
||||
const health = HEALTH[p.status] || HEALTH.inactive
|
||||
const hasUpdate = updateAvailable(p)
|
||||
const statusBadge = STATUS_BADGE[p.status]
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-edge bg-surface-secondary/40 p-3.5 flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl grid place-items-center bg-surface-card border border-edge shrink-0">
|
||||
<Blocks size={18} className="text-content-muted" />
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-11 h-11 rounded-xl grid place-items-center bg-surface-card border border-edge">
|
||||
<PluginIcon name={p.icon} className="text-content-muted" />
|
||||
</div>
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||
title={t(`admin.plugins.status.${p.status}` as never)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content truncate">{p.name}</span>
|
||||
{p.version && <span className="text-[11px] text-content-faint font-medium">v{p.version}</span>}
|
||||
{p.reviewed_at && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-emerald-600">
|
||||
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
|
||||
<TypeBadge type={p.type} t={t} />
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{statusBadge && (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${statusBadge}`}>
|
||||
{t(`admin.plugins.status.${p.status}` as never)}
|
||||
</span>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<button onClick={() => act(p.id, async () => { await adminApi.pluginInstall(p.id); await adminApi.pluginActivate(p.id) }, t('admin.plugins.updated'))}
|
||||
<button onClick={() => act(p.id, async () => {
|
||||
// Restart around the install so the running child actually picks up
|
||||
// the new code, and keep the admin's ON/OFF intent as it was.
|
||||
const wasEnabled = p.enabled === 1
|
||||
if (wasEnabled) await adminApi.pluginDeactivate(p.id)
|
||||
await adminApi.pluginInstall(p.id)
|
||||
if (wasEnabled) await adminApi.pluginActivate(p.id)
|
||||
}, t('admin.plugins.updated'))}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 transition-colors">
|
||||
<ArrowUpCircle size={11} /> {t('admin.plugins.updateTo', { version: latest[p.id] })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${health.cls}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${health.dot}`} />
|
||||
{t(`admin.plugins.status.${p.status}` as never)}
|
||||
</span>
|
||||
<span className="text-[11px] text-content-faint">· {t(`admin.plugins.type.${p.type}` as never)}</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="text-xs text-content-faint mt-0.5 line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
{p.source_repo && (
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer"
|
||||
title={t('admin.plugins.sourceRepo')}
|
||||
className="flex items-center gap-1 max-w-fit min-w-0 text-[11px] text-content-faint hover:text-content transition-colors mt-1">
|
||||
<Github size={11} className="shrink-0" /> <span className="truncate">{p.source_repo}</span>
|
||||
</a>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error && (
|
||||
<p className="text-[11px] text-rose-500/90 mt-1 truncate">{p.last_error}</p>
|
||||
)}
|
||||
@@ -230,6 +319,14 @@ export default function AdminPluginsPanel() {
|
||||
<p className="text-xs text-content-faint">{t('admin.plugins.trustNote')}</p>
|
||||
</div>
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
installed={installedIds.has(detailFor.id)}
|
||||
onInstall={install}
|
||||
onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
@@ -278,11 +375,27 @@ function IconBtn({ children, title, onClick, disabled, danger }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, busy, t, installedIds }: {
|
||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
<div className={`bg-surface-tertiary overflow-hidden ${className}`}>
|
||||
{url && !failed ? (
|
||||
<img src={url} alt="" loading="lazy" className="w-full h-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center">
|
||||
<Blocks size={iconSize} className="text-content-faint/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: (k: string, p?: Record<string, unknown>) => string
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
}) {
|
||||
if (!items) return <div className="py-10 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
@@ -292,32 +405,163 @@ function RegistryGrid({ items, onInstall, busy, t, installedIds }: {
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
return (
|
||||
<div key={item.id} className="border border-edge rounded-xl p-4 bg-surface-secondary/40 flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-lg grid place-items-center bg-surface-card border border-edge shrink-0">
|
||||
<Blocks size={16} className="text-content-muted" />
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="border border-edge rounded-xl bg-surface-secondary/40 overflow-hidden flex flex-col cursor-pointer hover:border-accent/50 transition-colors">
|
||||
<Screenshot url={item.screenshotUrl} className="aspect-video" />
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content truncate">{item.name}</span>
|
||||
{item.latest && <span className="text-[10px] text-content-faint">v{item.latest}</span>}
|
||||
<TypeBadge type={item.type} t={t} />
|
||||
{item.reviewedAt && <ReviewedBadge t={t} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-content truncate">{item.name}</span>
|
||||
{item.latest && <span className="text-[10px] text-content-faint">v{item.latest}</span>}
|
||||
</div>
|
||||
<span className="text-[11px] text-content-faint">{item.author} · {t(`admin.plugins.type.${item.type}` as never)}</span>
|
||||
<span className="text-[11px] text-content-faint mt-0.5">{item.author}</span>
|
||||
<p className="text-xs text-content-faint mt-2 line-clamp-2 flex-1">{item.description}</p>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="inline-flex items-center gap-0.5 text-[11px] font-medium text-accent">
|
||||
{t('admin.plugins.details')} <ChevronRight size={12} />
|
||||
</span>
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id) }} disabled={busy === item.id || installed}
|
||||
className="text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-2.5 line-clamp-2 flex-1">{item.description}</p>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{item.reviewedAt
|
||||
? <span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-600"><ShieldCheck size={12} /> {t('admin.plugins.reviewed')}</span>
|
||||
: <span className="text-[10px] text-content-faint">{t('admin.plugins.unreviewed')}</span>}
|
||||
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
|
||||
className="text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem
|
||||
installed: boolean
|
||||
busy: string | null
|
||||
onInstall: (id: string) => void
|
||||
onClose: () => void
|
||||
t: T
|
||||
locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
adminApi.pluginDetail(item.id)
|
||||
.then((d: RegistryDetail) => { if (alive) setDetail(d) })
|
||||
.catch(() => { if (alive) setFailed(true) })
|
||||
return () => { alive = false }
|
||||
}, [item.id])
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const repoUrl = `https://github.com/${item.repo}`
|
||||
// Registry data is curated but still remote: only ever link plain http(s) URLs.
|
||||
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-xl overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative shrink-0">
|
||||
<Screenshot url={item.screenshotUrl} className="aspect-video max-h-64 w-full" iconSize={36} />
|
||||
<button onClick={onClose}
|
||||
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-base font-semibold text-content">{item.name}</h3>
|
||||
{item.latest && <span className="text-[11px] text-content-faint font-medium">v{item.latest}</span>}
|
||||
<TypeBadge type={item.type} t={t} />
|
||||
{item.reviewedAt && <ReviewedBadge t={t} />}
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-0.5">{item.author}</p>
|
||||
|
||||
<p className="text-sm text-content-muted mt-3">{item.description}</p>
|
||||
|
||||
{failed && <p className="text-xs text-rose-500 mt-3">{t('admin.plugins.detailError')}</p>}
|
||||
|
||||
{/* Permissions */}
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.permissionsTitle')}</h4>
|
||||
{manifest.permissions.length === 0 ? (
|
||||
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noPermissions')}</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{manifest.permissions.map(perm => (
|
||||
<li key={perm} className="flex items-start gap-2 text-xs text-content-muted">
|
||||
<Check size={13} className="text-accent mt-0.5 shrink-0" />
|
||||
{PERM_KEYS.includes(perm)
|
||||
? <span>{t(`admin.plugins.perm.${perm}` as never)}</span>
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{manifest.egress.length > 0 && (
|
||||
<p className="text-[11px] text-content-faint mt-2">
|
||||
{t('admin.plugins.egressNote', { hosts: manifest.egress.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup preview */}
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
|
||||
{manifest.settings.length === 0 ? (
|
||||
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noSetup')}</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{manifest.settings.map(s => (
|
||||
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">
|
||||
{t(`admin.plugins.scope.${s.scope}` as never)}
|
||||
</span>
|
||||
{s.required && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600">
|
||||
{t('admin.plugins.fieldRequired')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="mt-5 flex items-center gap-2 flex-wrap">
|
||||
<a href={repoUrl} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<Github size={13} /> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
{homepage && (
|
||||
<a href={homepage} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<ExternalLink size={13} /> {t('admin.plugins.homepage')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="text-[11px] text-content-faint flex items-center gap-3 flex-wrap">
|
||||
{item.minTrekVersion && <span>{t('admin.plugins.requiresTrek', { version: item.minTrekVersion })}</span>}
|
||||
{item.reviewedAt && <span>{t('admin.plugins.reviewedOn', { date: new Date(item.reviewedAt).toLocaleDateString(locale) })}</span>}
|
||||
</div>
|
||||
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
|
||||
className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint shrink-0">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user