1276 lines
53 KiB
TypeScript
1276 lines
53 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useRouter } from 'next/router'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import {
|
|
Plug2, CheckCircle2, XCircle, Settings, ChevronDown,
|
|
ChevronUp, Loader2, AlertTriangle, Eye, EyeOff, Save,
|
|
RefreshCw, ShieldCheck, Package, Database, Trash2, Plus,
|
|
Info, MessageSquare, FileText, Image, Users, CreditCard,
|
|
Stethoscope, ScanLine, Download, Film,
|
|
} from 'lucide-react'
|
|
import { useAuth } from '@/context/AuthContext'
|
|
import { api } from '@/services/chatApiService'
|
|
import { PageHeader, Card, Badge } from '@/components/ui'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface ConfigField {
|
|
key: string
|
|
label: string
|
|
type: 'text' | 'password' | 'select' | 'toggle' | 'model_select' | 'provider_chain'
|
|
placeholder?: string
|
|
description?: string
|
|
tooltip?: string
|
|
options?: Array<{ label: string; value: string }>
|
|
required?: boolean
|
|
group?: string
|
|
default?: string
|
|
}
|
|
|
|
interface Plugin {
|
|
name: string
|
|
displayName: string
|
|
version: string
|
|
description: string
|
|
author: string
|
|
category: string
|
|
active: boolean
|
|
canDisable: boolean
|
|
dependencies: string[]
|
|
configSchema: ConfigField[]
|
|
config: Record<string, string>
|
|
hooks: { subscribes: string[]; emits: string[] }
|
|
}
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
core: 'Core',
|
|
business: 'Negócio',
|
|
integration: 'Integração',
|
|
utility: 'Utilitário',
|
|
}
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
core: 'bg-blue-500/10 text-blue-400 border border-blue-500/20',
|
|
business: 'bg-green-500/10 text-green-400 border border-green-500/20',
|
|
integration: 'bg-purple-500/10 text-purple-400 border border-purple-500/20',
|
|
utility: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20',
|
|
}
|
|
|
|
// ─── Modelos conhecidos por provider ─────────────────────────────────────────
|
|
|
|
const MODELS_BY_PROVIDER: Record<string, { label: string; value: string }[]> = {
|
|
openai: [
|
|
{ label: 'GPT-4o (melhor qualidade)', value: 'gpt-4o' },
|
|
{ label: 'GPT-4o Mini (rápido / barato)', value: 'gpt-4o-mini' },
|
|
{ label: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
|
|
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
|
|
],
|
|
anthropic: [
|
|
{ label: 'Claude Sonnet 4.5 (melhor)', value: 'claude-sonnet-4-5' },
|
|
{ label: 'Claude 3.5 Haiku (rápido / barato)', value: 'claude-3-5-haiku-20241022' },
|
|
{ label: 'Claude 3.5 Sonnet', value: 'claude-3-5-sonnet-20241022' },
|
|
],
|
|
gemini: [
|
|
{ label: 'Gemini 2.0 Flash (recomendado)', value: 'gemini-2.0-flash' },
|
|
{ label: 'Gemini 2.0 Flash Lite (mais barato)', value: 'gemini-2.0-flash-lite' },
|
|
{ label: 'Gemini 2.5 Pro (mais poderoso)', value: 'gemini-2.5-pro-preview-03-25' },
|
|
{ label: 'Gemini 1.5 Pro', value: 'gemini-1.5-pro-latest' },
|
|
],
|
|
ollama: [
|
|
{ label: 'Llama 3.1 (recomendado)', value: 'llama3.1' },
|
|
{ label: 'Llama 3', value: 'llama3' },
|
|
{ label: 'Mistral', value: 'mistral' },
|
|
{ label: 'Gemma 2', value: 'gemma2' },
|
|
{ label: 'Phi-3', value: 'phi3' },
|
|
{ label: 'Qwen 2.5', value: 'qwen2.5' },
|
|
],
|
|
}
|
|
|
|
// normaliza options que podem vir como string[] ou {label,value}[]
|
|
function normalizeOptions(opts: any[]): { label: string; value: string }[] {
|
|
return opts.map(o => typeof o === 'string' ? { label: o, value: o } : o)
|
|
}
|
|
|
|
// ─── Provider Chain Field ─────────────────────────────────────────────────────
|
|
|
|
const PROVIDER_LABELS: Record<string, { label: string; color: string }> = {
|
|
openai: { label: 'OpenAI', color: 'text-green-400' },
|
|
gemini: { label: 'Gemini', color: 'text-blue-400' },
|
|
anthropic: { label: 'Anthropic', color: 'text-purple-400' },
|
|
ollama: { label: 'Ollama', color: 'text-yellow-400' },
|
|
}
|
|
|
|
const ALL_PROVIDERS = ['openai', 'gemini', 'anthropic', 'ollama']
|
|
|
|
function ProviderChainField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
const chain = (value || 'openai,gemini,anthropic,ollama')
|
|
.split(',').map(s => s.trim()).filter(Boolean)
|
|
|
|
// Garante que todos os providers existam na lista
|
|
const full = [
|
|
...chain,
|
|
...ALL_PROVIDERS.filter(p => !chain.includes(p)),
|
|
]
|
|
|
|
const move = (idx: number, dir: -1 | 1) => {
|
|
const next = [...full]
|
|
const swap = idx + dir
|
|
if (swap < 0 || swap >= next.length) return
|
|
;[next[idx], next[swap]] = [next[swap], next[idx]]
|
|
onChange(next.join(','))
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{full.map((p, idx) => {
|
|
const meta = PROVIDER_LABELS[p] ?? { label: p, color: 'text-slate-400' }
|
|
return (
|
|
<div key={p} className="flex items-center gap-2 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2">
|
|
<span className="text-[10px] font-black text-slate-700 w-4">{idx + 1}</span>
|
|
<span className={`flex-1 text-xs font-bold ${meta.color}`}>{meta.label}</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => move(idx, -1)}
|
|
disabled={idx === 0}
|
|
className="w-6 h-6 rounded-lg flex items-center justify-center text-slate-600 hover:text-white hover:bg-white/10 disabled:opacity-20 transition-all"
|
|
>
|
|
<ChevronUp size={12} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => move(idx, 1)}
|
|
disabled={idx === full.length - 1}
|
|
className="w-6 h-6 rounded-lg flex items-center justify-center text-slate-600 hover:text-white hover:bg-white/10 disabled:opacity-20 transition-all"
|
|
>
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
<p className="text-[10px] text-slate-700 pt-1">
|
|
Use ↑↓ para reordenar. Providers sem chave configurada são pulados automaticamente.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Info Tooltip (portal — escapa do overflow do modal) ─────────────────────
|
|
|
|
function InfoTooltip({ text }: { text: string }) {
|
|
const btnRef = useRef<HTMLButtonElement>(null)
|
|
const [rect, setRect] = useState<DOMRect | null>(null)
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => { setMounted(true) }, [])
|
|
|
|
const show = () => setRect(btnRef.current?.getBoundingClientRect() ?? null)
|
|
const hide = () => setRect(null)
|
|
|
|
const tooltipLeft = rect ? Math.min(rect.right + 10, window.innerWidth - 270) : 0
|
|
const tooltipTop = rect ? rect.top + rect.height / 2 : 0
|
|
|
|
return (
|
|
<span className="inline-flex items-center ml-1.5 align-middle shrink-0">
|
|
<button
|
|
ref={btnRef}
|
|
type="button"
|
|
onMouseEnter={show}
|
|
onMouseLeave={hide}
|
|
onFocus={show}
|
|
onBlur={hide}
|
|
className="text-slate-600 hover:text-blue-400 transition-colors focus:outline-none"
|
|
tabIndex={-1}
|
|
>
|
|
<Info size={12} />
|
|
</button>
|
|
|
|
{mounted && rect && createPortal(
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
left: tooltipLeft,
|
|
top: tooltipTop,
|
|
transform: 'translateY(-50%)',
|
|
zIndex: 9999,
|
|
}}
|
|
className="w-64 bg-[#1a1d27] border border-white/15 rounded-xl px-3 py-2.5 text-[11px] text-slate-300 leading-relaxed shadow-2xl pointer-events-none"
|
|
>
|
|
{text}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Category Buckets Editor ───────────────────────────────────────────────────
|
|
|
|
const CATEGORY_META: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
|
|
whatsapp: { icon: <MessageSquare size={13} />, label: 'WhatsApp', color: 'text-green-400 bg-green-500/10' },
|
|
media: { icon: <Film size={13} />, label: 'Media', color: 'text-blue-400 bg-blue-500/10' },
|
|
documents: { icon: <FileText size={13} />, label: 'Documents', color: 'text-amber-400 bg-amber-500/10' },
|
|
posts: { icon: <Image size={13} />, label: 'Posts', color: 'text-purple-400 bg-purple-500/10' },
|
|
cards: { icon: <CreditCard size={13} />, label: 'Cards', color: 'text-pink-400 bg-pink-500/10' },
|
|
partners: { icon: <Users size={13} />, label: 'Partners', color: 'text-cyan-400 bg-cyan-500/10' },
|
|
cdi: { icon: <Stethoscope size={13} />, label: 'CDI', color: 'text-red-400 bg-red-500/10' },
|
|
xrays: { icon: <ScanLine size={13} />, label: 'Xrays', color: 'text-indigo-400 bg-indigo-500/10' },
|
|
exports: { icon: <Download size={13} />, label: 'Exports', color: 'text-slate-400 bg-slate-500/10' },
|
|
}
|
|
|
|
function CategoryBucketsEditor({
|
|
fields,
|
|
form,
|
|
setForm,
|
|
mainBucket,
|
|
}: {
|
|
fields: ConfigField[]
|
|
form: Record<string, string>
|
|
setForm: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
|
mainBucket: string
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<p className="text-[11px] text-slate-600 leading-relaxed mb-3">
|
|
Cada categoria de arquivo pode ser enviada para um bucket dedicado.
|
|
Deixe em branco para usar o <span className="text-slate-400 font-mono">{mainBucket || 'Bucket Principal'}</span>.
|
|
</p>
|
|
{fields.map(field => {
|
|
const cat = field.key.replace('category_bucket_', '')
|
|
const meta = CATEGORY_META[cat]
|
|
const value = form[field.key] ?? ''
|
|
return (
|
|
<div
|
|
key={field.key}
|
|
className="flex items-center gap-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-3 py-2.5"
|
|
>
|
|
{/* Category badge */}
|
|
<div className={`flex items-center gap-1.5 w-28 shrink-0 rounded-lg px-2 py-1 ${meta?.color ?? 'text-slate-400 bg-slate-500/10'}`}>
|
|
{meta?.icon}
|
|
<span className="text-[11px] font-bold">{meta?.label ?? cat}</span>
|
|
</div>
|
|
|
|
{/* Arrow */}
|
|
<span className="text-slate-700 text-xs shrink-0">→</span>
|
|
|
|
{/* Input */}
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={e => setForm(f => ({ ...f, [field.key]: e.target.value }))}
|
|
placeholder={mainBucket || field.placeholder || 'bucket principal'}
|
|
className="flex-1 bg-transparent text-sm text-white placeholder-slate-700 focus:outline-none min-w-0"
|
|
/>
|
|
|
|
{/* Tooltip */}
|
|
{field.tooltip && <InfoTooltip text={field.tooltip} />}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Config Modal ──────────────────────────────────────────────────────────────
|
|
|
|
const GROUP_META: Record<string, { icon: React.ReactNode; color: string; defaultOpen?: boolean }> = {
|
|
'Credenciais Wasabi': { icon: <ShieldCheck size={14} />, color: 'text-emerald-400', defaultOpen: true },
|
|
'Armazenamento': { icon: <Database size={14} />, color: 'text-blue-400', defaultOpen: true },
|
|
'Avançado': { icon: <Settings size={14} />, color: 'text-slate-400', defaultOpen: false },
|
|
'Buckets por Categoria': { icon: <Package size={14} />, color: 'text-purple-400', defaultOpen: true },
|
|
'Geral': { icon: <Settings size={14} />, color: 'text-slate-400', defaultOpen: true },
|
|
}
|
|
|
|
function ConfigFieldInput({
|
|
field,
|
|
value,
|
|
onChange,
|
|
showSecret,
|
|
onToggleSecret,
|
|
form,
|
|
}: {
|
|
field: ConfigField
|
|
value: string
|
|
onChange: (v: string) => void
|
|
showSecret: boolean
|
|
onToggleSecret: () => void
|
|
form: Record<string, string>
|
|
}) {
|
|
if (field.type === 'toggle') {
|
|
return (
|
|
<button
|
|
onClick={() => onChange(value === 'true' ? 'false' : 'true')}
|
|
className={`w-12 h-6 rounded-full transition-colors ${value === 'true' ? 'bg-brand-500' : 'bg-white/10'}`}
|
|
>
|
|
<span className={`block w-4 h-4 rounded-full bg-white mx-1 transition-transform ${value === 'true' ? 'translate-x-6' : ''}`} />
|
|
</button>
|
|
)
|
|
}
|
|
if (field.type === 'select') {
|
|
return (
|
|
<select
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
className="w-full bg-[#12151e] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500/50"
|
|
>
|
|
<option value="" className="bg-[#12151e]">Selecionar...</option>
|
|
{normalizeOptions(field.options ?? []).map(opt => (
|
|
<option key={opt.value} value={opt.value} className="bg-[#12151e]">{opt.label}</option>
|
|
))}
|
|
</select>
|
|
)
|
|
}
|
|
if (field.type === 'model_select') {
|
|
const provider = form['default_provider'] ?? ''
|
|
const models = MODELS_BY_PROVIDER[provider] ?? Object.values(MODELS_BY_PROVIDER).flat()
|
|
return (
|
|
<select
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
className="w-full bg-[#12151e] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500/50"
|
|
>
|
|
<option value="" className="bg-[#12151e]">Auto (padrão do provider)</option>
|
|
{models.map(m => (
|
|
<option key={m.value} value={m.value} className="bg-[#12151e]">{m.label}</option>
|
|
))}
|
|
</select>
|
|
)
|
|
}
|
|
if (field.type === 'provider_chain') {
|
|
return (
|
|
<ProviderChainField
|
|
value={value || field.default || 'openai,gemini,anthropic,ollama'}
|
|
onChange={onChange}
|
|
/>
|
|
)
|
|
}
|
|
// text / password
|
|
return (
|
|
<div className="relative">
|
|
<input
|
|
type={field.type === 'password' && !showSecret ? 'password' : 'text'}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-brand-500/40 transition-colors pr-10"
|
|
/>
|
|
{field.type === 'password' && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleSecret}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-white transition-colors"
|
|
>
|
|
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConfigGroup({
|
|
group,
|
|
fields,
|
|
form,
|
|
setForm,
|
|
showSecrets,
|
|
setShowSecrets,
|
|
}: {
|
|
group: string
|
|
fields: ConfigField[]
|
|
form: Record<string, string>
|
|
setForm: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
|
showSecrets: Record<string, boolean>
|
|
setShowSecrets: React.Dispatch<React.SetStateAction<Record<string, boolean>>>
|
|
}) {
|
|
const meta = GROUP_META[group] ?? GROUP_META['Geral']
|
|
const [open, setOpen] = useState(meta.defaultOpen ?? true)
|
|
const isCategoryGroup = group === 'Buckets por Categoria'
|
|
|
|
return (
|
|
<div className="border border-white/[0.07] rounded-2xl overflow-hidden">
|
|
{/* Group header */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(o => !o)}
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<span className={meta.color}>{meta.icon}</span>
|
|
<span className="text-xs font-bold text-slate-300 tracking-wide">{group}</span>
|
|
{!meta.defaultOpen && !open && (
|
|
<span className="text-[10px] text-slate-600 font-normal">raramente necessário</span>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
size={14}
|
|
className={`text-slate-600 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Group body */}
|
|
{open && (
|
|
<div className="px-4 py-4 space-y-4 border-t border-white/[0.05]">
|
|
{isCategoryGroup ? (
|
|
<CategoryBucketsEditor
|
|
fields={fields}
|
|
form={form}
|
|
setForm={setForm}
|
|
mainBucket={form['wasabiBucket'] ?? ''}
|
|
/>
|
|
) : (
|
|
fields.map(field => (
|
|
<div key={field.key}>
|
|
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
|
|
<span>{field.label}</span>
|
|
{field.required && <span className="text-red-400">*</span>}
|
|
{field.tooltip && <InfoTooltip text={field.tooltip} />}
|
|
</label>
|
|
{field.description && (
|
|
<p className="text-[11px] text-slate-600 mb-1.5 leading-relaxed">{field.description}</p>
|
|
)}
|
|
<ConfigFieldInput
|
|
field={field}
|
|
value={form[field.key] ?? ''}
|
|
onChange={v => setForm(f => ({ ...f, [field.key]: v }))}
|
|
showSecret={showSecrets[field.key] ?? false}
|
|
onToggleSecret={() => setShowSecrets(s => ({ ...s, [field.key]: !s[field.key] }))}
|
|
form={form}
|
|
/>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConfigModal({ plugin, onClose, onSaved }: {
|
|
plugin: Plugin
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
}) {
|
|
const [form, setForm] = useState<Record<string, string>>(plugin.config ?? {})
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
|
|
|
const groups = Array.from(new Set(plugin.configSchema.map(f => f.group ?? 'Geral')))
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
setError('')
|
|
try {
|
|
await api.put(`/api/admin/plugins/${plugin.name}/config`, form)
|
|
onSaved()
|
|
onClose()
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.error ?? 'Erro ao salvar configuração')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
className="bg-[#0d1018] border border-white/10 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col max-h-[90vh]"
|
|
>
|
|
{/* Header — fixed, não rola */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.07] shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-white/5 flex items-center justify-center">
|
|
<Settings size={15} className="text-slate-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-white font-bold text-base leading-tight">{plugin.displayName}</h2>
|
|
<p className="text-slate-600 text-xs">v{plugin.version}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 flex items-center justify-center rounded-xl text-slate-500 hover:text-white hover:bg-white/8 transition-colors"
|
|
>
|
|
<XCircle size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body — só esta parte rola */}
|
|
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-3">
|
|
{groups.map(group => {
|
|
const fields = plugin.configSchema.filter(f => (f.group ?? 'Geral') === group)
|
|
return (
|
|
<ConfigGroup
|
|
key={group}
|
|
group={group}
|
|
fields={fields}
|
|
form={form}
|
|
setForm={setForm}
|
|
showSecrets={showSecrets}
|
|
setShowSecrets={setShowSecrets}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-4 py-3 text-sm">
|
|
<AlertTriangle size={15} />
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer — fixed, não rola */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.07] shrink-0">
|
|
<p className="text-[11px] text-slate-700">
|
|
Passe o mouse sobre <Info size={10} className="inline mb-0.5 text-blue-400" /> para ver detalhes de cada campo
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors rounded-xl hover:bg-white/5"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white text-sm font-semibold rounded-xl transition-colors disabled:opacity-50"
|
|
>
|
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
Salvar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Wasabi Storage Modal ─────────────────────────────────────────────────────
|
|
|
|
const WASABI_REGIONS = [
|
|
{ region: 'us-east-1', endpoint: 's3.wasabisys.com', label: 'US East 1 (N. Virginia)' },
|
|
{ region: 'us-east-2', endpoint: 's3.us-east-2.wasabisys.com', label: 'US East 2 (N. Virginia)' },
|
|
{ region: 'us-central-1', endpoint: 's3.us-central-1.wasabisys.com', label: 'US Central 1 (Texas)' },
|
|
{ region: 'us-west-1', endpoint: 's3.us-west-1.wasabisys.com', label: 'US West 1 (Oregon)' },
|
|
{ region: 'eu-central-1', endpoint: 's3.eu-central-1.wasabisys.com', label: 'EU Central 1 (Amsterdam)' },
|
|
{ region: 'eu-central-2', endpoint: 's3.eu-central-2.wasabisys.com', label: 'EU Central 2 (Frankfurt)' },
|
|
{ region: 'eu-west-1', endpoint: 's3.eu-west-1.wasabisys.com', label: 'EU West 1 (London)' },
|
|
{ region: 'eu-west-2', endpoint: 's3.eu-west-2.wasabisys.com', label: 'EU West 2 (Paris)' },
|
|
{ region: 'eu-south-1', endpoint: 's3.eu-south-1.wasabisys.com', label: 'EU South 1 (Milan)' },
|
|
{ region: 'ap-northeast-1', endpoint: 's3.ap-northeast-1.wasabisys.com', label: 'AP Northeast 1 (Tokyo)' },
|
|
{ region: 'ap-northeast-2', endpoint: 's3.ap-northeast-2.wasabisys.com', label: 'AP Northeast 2 (Osaka)' },
|
|
{ region: 'ap-southeast-1', endpoint: 's3.ap-southeast-1.wasabisys.com', label: 'AP Southeast 1 (Singapore)' },
|
|
{ region: 'ap-southeast-2', endpoint: 's3.ap-southeast-2.wasabisys.com', label: 'AP Southeast 2 (Sydney)' },
|
|
]
|
|
|
|
type FetchStatus = 'idle' | 'loading' | 'ok' | 'error'
|
|
|
|
interface WasabiBucketItem { name: string; createdAt?: string }
|
|
|
|
function WasabiStorageModal({ plugin, onClose, onSaved }: {
|
|
plugin: Plugin
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
}) {
|
|
const cfg = plugin.config ?? {}
|
|
|
|
// Credenciais
|
|
const [accessKey, setAccessKey] = useState(cfg.wasabiAccessKey ?? '')
|
|
const [secretKey, setSecretKey] = useState(cfg.wasabiSecretKey ?? '')
|
|
const [region, setRegion] = useState(cfg.wasabiRegion ?? 'us-east-1')
|
|
const [endpoint, setEndpoint] = useState(cfg.wasabiEndpoint ?? '')
|
|
const [showKey, setShowKey] = useState(false)
|
|
const [showAdvanced, setShowAdvanced] = useState(!!(cfg.wasabiRegion || cfg.wasabiEndpoint))
|
|
|
|
// Buckets
|
|
const [buckets, setBuckets] = useState<WasabiBucketItem[]>([])
|
|
const [selectedBucket, setSelectedBucket] = useState(cfg.wasabiBucket ?? '')
|
|
const [fetchStatus, setFetchStatus] = useState<FetchStatus>('idle')
|
|
const [fetchError, setFetchError] = useState('')
|
|
|
|
// Criar bucket
|
|
const [newName, setNewName] = useState('')
|
|
const [creating, setCreating] = useState(false)
|
|
const [createErr, setCreateErr] = useState('')
|
|
|
|
// Salvar
|
|
const [saving, setSaving] = useState(false)
|
|
const [saveErr, setSaveErr] = useState('')
|
|
|
|
// Auto-busca se já tiver credenciais salvas
|
|
useEffect(() => {
|
|
if (cfg.wasabiAccessKey && cfg.wasabiSecretKey) fetchBuckets()
|
|
}, []) // eslint-disable-line
|
|
|
|
const fetchBuckets = async () => {
|
|
if (!accessKey || !secretKey) return
|
|
setFetchStatus('loading')
|
|
setFetchError('')
|
|
try {
|
|
const res = await api.post<{ buckets: WasabiBucketItem[] }>('/api/storage/wasabi/list-buckets', {
|
|
accessKey, secretKey,
|
|
region: region || undefined,
|
|
endpoint: endpoint || undefined,
|
|
})
|
|
setBuckets(res.data.buckets)
|
|
setFetchStatus('ok')
|
|
} catch (err: any) {
|
|
setFetchStatus('error')
|
|
setFetchError(err?.response?.data?.error ?? 'Falha na conexão com a Wasabi')
|
|
}
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
if (!newName.trim()) return
|
|
setCreating(true)
|
|
setCreateErr('')
|
|
try {
|
|
const res = await api.post<{ name: string }>('/api/storage/wasabi/create-bucket', {
|
|
accessKey, secretKey,
|
|
region: region || undefined,
|
|
endpoint: endpoint || undefined,
|
|
name: newName.trim(),
|
|
})
|
|
await fetchBuckets()
|
|
setSelectedBucket(res.data.name)
|
|
setNewName('')
|
|
} catch (err: any) {
|
|
setCreateErr(err?.response?.data?.error ?? 'Erro ao criar bucket')
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!selectedBucket) { setSaveErr('Selecione um bucket'); return }
|
|
setSaving(true)
|
|
setSaveErr('')
|
|
try {
|
|
const payload: Record<string, string> = {
|
|
wasabiAccessKey: accessKey,
|
|
wasabiSecretKey: secretKey,
|
|
wasabiBucket: selectedBucket,
|
|
}
|
|
if (region) payload.wasabiRegion = region
|
|
if (endpoint) payload.wasabiEndpoint = endpoint
|
|
await api.put(`/api/admin/plugins/${plugin.name}/config`, payload)
|
|
onSaved()
|
|
onClose()
|
|
} catch (err: any) {
|
|
setSaveErr(err?.response?.data?.error ?? 'Erro ao salvar')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const canFetch = accessKey.length > 0 && secretKey.length > 0
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
className="bg-[#0d1018] border border-white/10 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col max-h-[90vh]"
|
|
>
|
|
{/* ── Header ── */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.07] shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 flex items-center justify-center">
|
|
<Database size={17} className="text-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-white font-bold text-base leading-tight">Wasabi Storage</h2>
|
|
<p className="text-slate-600 text-xs">Armazenamento de mídias do WhatsApp</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 flex items-center justify-center rounded-xl text-slate-600 hover:text-white hover:bg-white/8 transition-colors"
|
|
>
|
|
<XCircle size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Body (scrollable) ── */}
|
|
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
|
|
|
|
{/* Credenciais */}
|
|
<div className="space-y-3">
|
|
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">Credenciais Wasabi</p>
|
|
|
|
<div>
|
|
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
|
|
Access Key <span className="text-red-400">*</span>
|
|
<InfoTooltip text="Chave de acesso pública gerada em Account → Access Keys no painel Wasabi." />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={accessKey}
|
|
onChange={e => setAccessKey(e.target.value)}
|
|
placeholder="NK2RNWUWBNGC169FSN1N"
|
|
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-emerald-500/40 font-mono transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
|
|
Secret Key <span className="text-red-400">*</span>
|
|
<InfoTooltip text="Chave secreta correspondente à Access Key — exibida apenas uma vez no momento da criação no painel Wasabi." />
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showKey ? 'text' : 'password'}
|
|
value={secretKey}
|
|
onChange={e => setSecretKey(e.target.value)}
|
|
placeholder="●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●"
|
|
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-emerald-500/40 font-mono transition-colors pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowKey(v => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-white transition-colors"
|
|
>
|
|
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Avançado */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(v => !v)}
|
|
className="flex items-center gap-1.5 text-[11px] text-slate-600 hover:text-slate-400 transition-colors"
|
|
>
|
|
<ChevronDown size={12} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
|
Configurações avançadas (region / endpoint)
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="space-y-3 pl-1">
|
|
<div>
|
|
<label className="flex items-center gap-1 text-xs font-semibold text-slate-500 mb-1.5">
|
|
Região
|
|
<InfoTooltip text="Selecione a região onde seu bucket foi criado. O endpoint é preenchido automaticamente." />
|
|
</label>
|
|
<select
|
|
value={region}
|
|
onChange={e => {
|
|
const r = e.target.value
|
|
setRegion(r)
|
|
const ep = WASABI_REGIONS.find(x => x.region === r)?.endpoint ?? ''
|
|
setEndpoint(ep === 's3.wasabisys.com' ? '' : ep)
|
|
}}
|
|
className="w-full bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-white/20 font-mono appearance-none"
|
|
>
|
|
{WASABI_REGIONS.map(r => (
|
|
<option key={r.region} value={r.region} className="bg-[#13151f]">
|
|
{r.region} — {r.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="flex items-center gap-1 text-xs font-semibold text-slate-500 mb-1.5">
|
|
Endpoint
|
|
<InfoTooltip text="Preenchido automaticamente ao selecionar a região. Só altere manualmente se necessário." />
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={endpoint || WASABI_REGIONS.find(x => x.region === region)?.endpoint || 's3.wasabisys.com'}
|
|
onChange={e => setEndpoint(e.target.value)}
|
|
className="flex-1 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-slate-400 placeholder-slate-700 focus:outline-none focus:border-white/20 font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Botão buscar */}
|
|
<button
|
|
type="button"
|
|
onClick={fetchBuckets}
|
|
disabled={!canFetch || fetchStatus === 'loading'}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40 disabled:cursor-not-allowed w-full justify-center"
|
|
>
|
|
{fetchStatus === 'loading'
|
|
? <><Loader2 size={14} className="animate-spin" /> Conectando...</>
|
|
: <><RefreshCw size={14} /> Buscar Buckets</>
|
|
}
|
|
</button>
|
|
|
|
{fetchStatus === 'error' && (
|
|
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2.5 text-xs">
|
|
<AlertTriangle size={13} />
|
|
{fetchError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Lista de buckets */}
|
|
{fetchStatus === 'ok' && (
|
|
<div className="space-y-3">
|
|
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
|
|
Buckets disponíveis
|
|
<span className="ml-2 text-slate-700 font-normal normal-case">— clique para selecionar</span>
|
|
</p>
|
|
|
|
{buckets.length === 0 ? (
|
|
<p className="text-slate-600 text-xs text-center py-4">Nenhum bucket encontrado. Crie um abaixo.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{buckets.map(b => {
|
|
const active = b.name === selectedBucket
|
|
return (
|
|
<button
|
|
key={b.name}
|
|
type="button"
|
|
onClick={() => setSelectedBucket(b.name ?? '')}
|
|
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border text-left transition-all ${
|
|
active
|
|
? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-300'
|
|
: 'bg-white/[0.02] border-white/[0.06] text-slate-300 hover:bg-white/[0.05] hover:border-white/15'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${active ? 'border-emerald-400' : 'border-slate-600'}`}>
|
|
{active && <div className="w-2 h-2 rounded-full bg-emerald-400" />}
|
|
</div>
|
|
<span className="text-sm font-mono">{b.name}</span>
|
|
</div>
|
|
{b.createdAt && (
|
|
<span className="text-xs text-slate-600 shrink-0 ml-2">
|
|
{new Date(b.createdAt).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Criar novo bucket */}
|
|
<div className="border-t border-white/5 pt-3">
|
|
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest mb-2">Criar novo bucket</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newName}
|
|
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
|
|
onKeyDown={e => e.key === 'Enter' && handleCreate()}
|
|
placeholder="nome-do-bucket"
|
|
className="flex-1 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-white/20 font-mono"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreate}
|
|
disabled={creating || !newName.trim()}
|
|
className="flex items-center gap-1.5 px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40"
|
|
>
|
|
{creating ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />}
|
|
Criar
|
|
</button>
|
|
</div>
|
|
{createErr && (
|
|
<p className="text-red-400 text-xs mt-1.5 flex items-center gap-1">
|
|
<AlertTriangle size={11} /> {createErr}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bucket selecionado (feedback) */}
|
|
{selectedBucket && fetchStatus === 'ok' && (
|
|
<div className="flex items-center gap-2 bg-emerald-500/8 border border-emerald-500/20 rounded-xl px-4 py-3">
|
|
<CheckCircle2 size={14} className="text-emerald-400 shrink-0" />
|
|
<div>
|
|
<p className="text-emerald-300 text-xs font-semibold">Bucket ativo selecionado</p>
|
|
<p className="text-emerald-400/70 text-xs font-mono">{selectedBucket}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{saveErr && (
|
|
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2.5 text-xs">
|
|
<AlertTriangle size={13} />
|
|
{saveErr}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.07] shrink-0">
|
|
<p className="text-[11px] text-slate-700">
|
|
{fetchStatus === 'ok'
|
|
? `${buckets.length} bucket${buckets.length !== 1 ? 's' : ''} encontrado${buckets.length !== 1 ? 's' : ''}`
|
|
: 'Entre as credenciais e clique em Buscar'
|
|
}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors rounded-xl hover:bg-white/5"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving || !selectedBucket || !accessKey || !secretKey}
|
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40 disabled:cursor-not-allowed border border-emerald-500/20"
|
|
>
|
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
Salvar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Wasabi Buckets Panel ─────────────────────────────────────────────────────
|
|
|
|
interface WasabiBucket {
|
|
name: string
|
|
createdAt?: string
|
|
}
|
|
|
|
|
|
// ─── Plugin Card ───────────────────────────────────────────────────────────────
|
|
|
|
function PluginCard({ plugin, onToggle, onConfigure, toggling }: {
|
|
plugin: Plugin
|
|
onToggle: (name: string, enable: boolean) => void
|
|
onConfigure: (plugin: Plugin) => void
|
|
toggling: string | null
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const hasConfig = plugin.configSchema.length > 0
|
|
const hasHooks = plugin.hooks.subscribes.length > 0 || plugin.hooks.emits.length > 0
|
|
|
|
return (
|
|
<div className={`bg-white/[0.03] border rounded-2xl transition-all ${plugin.active ? 'border-white/10' : 'border-white/5 opacity-60'}`}>
|
|
<div className="p-5 flex items-start gap-4">
|
|
{/* Icon */}
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${plugin.active ? 'bg-brand-500/15' : 'bg-white/5'}`}>
|
|
<Package size={20} className={plugin.active ? 'text-brand-400' : 'text-slate-500'} />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-white font-semibold text-sm">{plugin.displayName}</span>
|
|
<span className="text-slate-600 text-xs">v{plugin.version}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${CATEGORY_COLORS[plugin.category] ?? 'bg-white/5 text-slate-400'}`}>
|
|
{CATEGORY_LABELS[plugin.category] ?? plugin.category}
|
|
</span>
|
|
{!plugin.canDisable && (
|
|
<span className="flex items-center gap-1 text-xs text-slate-500">
|
|
<ShieldCheck size={11} /> Core
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-slate-500 text-xs mt-1 line-clamp-2">{plugin.description}</p>
|
|
{plugin.dependencies.length > 0 && (
|
|
<p className="text-slate-600 text-xs mt-1">
|
|
Deps: {plugin.dependencies.join(', ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{hasConfig && (
|
|
<button
|
|
onClick={() => onConfigure(plugin)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium rounded-lg transition-colors"
|
|
>
|
|
<Settings size={13} />
|
|
Config
|
|
</button>
|
|
)}
|
|
|
|
{plugin.canDisable && (
|
|
<button
|
|
onClick={() => onToggle(plugin.name, !plugin.active)}
|
|
disabled={toggling === plugin.name}
|
|
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-50 ${plugin.active ? 'bg-brand-500' : 'bg-white/10'}`}
|
|
>
|
|
{toggling === plugin.name ? (
|
|
<Loader2 size={12} className="absolute inset-0 m-auto text-white animate-spin" />
|
|
) : (
|
|
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${plugin.active ? 'left-6' : 'left-1'}`} />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{(hasHooks || plugin.hooks) && (
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
className="text-slate-500 hover:text-white transition-colors"
|
|
>
|
|
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded: hooks info */}
|
|
<AnimatePresence>
|
|
{expanded && hasHooks && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-5 pb-5 pt-0 border-t border-white/5 mt-0 grid grid-cols-2 gap-4">
|
|
{plugin.hooks.subscribes.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2">Escuta</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{plugin.hooks.subscribes.map(h => (
|
|
<span key={h} className="text-xs bg-blue-500/10 text-blue-400 border border-blue-500/20 px-2 py-0.5 rounded-full">{h}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{plugin.hooks.emits.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2">Emite</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{plugin.hooks.emits.map(h => (
|
|
<span key={h} className="text-xs bg-green-500/10 text-green-400 border border-green-500/20 px-2 py-0.5 rounded-full">{h}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
type StorageHealthReason = 'ok' | 'not_configured' | 'needs_restart' | 'auth_error' | 'billing_error' | 'bucket_not_found' | 'network_error' | 'unknown'
|
|
|
|
interface StorageHealthStatus {
|
|
healthy: boolean
|
|
configured: boolean
|
|
reason: StorageHealthReason
|
|
message: string
|
|
}
|
|
|
|
const STORAGE_ALERT_CONFIG: Record<StorageHealthReason, { color: string; icon: string; label: string }> = {
|
|
ok: { color: 'border-green-500/30 bg-green-500/10 text-green-300', icon: '✓', label: 'Wasabi OK' },
|
|
not_configured: { color: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300', icon: '⚙', label: 'Wasabi não configurado' },
|
|
needs_restart: { color: 'border-blue-500/30 bg-blue-500/10 text-blue-300', icon: '↺', label: 'Reinicialização necessária' },
|
|
auth_error: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Chaves inválidas' },
|
|
billing_error: { color: 'border-orange-500/30 bg-orange-500/10 text-orange-300', icon: '!', label: 'Conta Wasabi — verificar pagamento' },
|
|
bucket_not_found:{ color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Bucket não encontrado' },
|
|
network_error: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Sem conexão com Wasabi' },
|
|
unknown: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '?', label: 'Erro desconhecido' },
|
|
}
|
|
|
|
export default function PluginsPage() {
|
|
const router = useRouter()
|
|
const { user, loading: authLoading } = useAuth()
|
|
const [plugins, setPlugins] = useState<Plugin[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [toggling, setToggling] = useState<string | null>(null)
|
|
const [configPlugin, setConfigPlugin] = useState<Plugin | null>(null)
|
|
const [toast, setToast] = useState<{ msg: string; type: 'ok' | 'err' } | null>(null)
|
|
const [storageHealth, setStorageHealth] = useState<StorageHealthStatus | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && user?.role !== 'admin') router.replace('/dashboard')
|
|
}, [authLoading, user, router])
|
|
|
|
const fetchStorageHealth = useCallback(async () => {
|
|
try {
|
|
const res = await api.get<StorageHealthStatus>('/api/admin/plugins/storage/health')
|
|
setStorageHealth(res.data)
|
|
} catch {
|
|
// silently ignore — storage health is informational only
|
|
}
|
|
}, [])
|
|
|
|
const fetchPlugins = useCallback(async () => {
|
|
try {
|
|
const res = await api.get<Plugin[]>('/api/admin/plugins')
|
|
setPlugins(res.data)
|
|
} catch {
|
|
showToast('Erro ao carregar plugins', 'err')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && user?.role === 'admin') {
|
|
fetchPlugins()
|
|
fetchStorageHealth()
|
|
}
|
|
}, [authLoading, user, fetchPlugins, fetchStorageHealth])
|
|
|
|
const showToast = (msg: string, type: 'ok' | 'err') => {
|
|
setToast({ msg, type })
|
|
setTimeout(() => setToast(null), 3500)
|
|
}
|
|
|
|
const handleToggle = async (name: string, enable: boolean) => {
|
|
setToggling(name)
|
|
try {
|
|
await api.post(`/api/admin/plugins/${name}/${enable ? 'enable' : 'disable'}`)
|
|
showToast(`Plugin ${enable ? 'ativado' : 'desativado'} com sucesso`, 'ok')
|
|
await fetchPlugins()
|
|
} catch (err: any) {
|
|
showToast(err?.response?.data?.error ?? 'Erro ao alterar plugin', 'err')
|
|
} finally {
|
|
setToggling(null)
|
|
}
|
|
}
|
|
|
|
if (authLoading || user?.role !== 'admin') return null
|
|
|
|
const activeCount = plugins.filter(p => p.active).length
|
|
const categories = ['core', 'integration', 'business', 'utility']
|
|
return (
|
|
<div className="min-h-screen bg-[#080a0e] text-white p-6 lg:p-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="w-9 h-9 rounded-xl bg-brand-500/15 flex items-center justify-center">
|
|
<Plug2 size={18} className="text-brand-400" />
|
|
</div>
|
|
<h1 className="text-2xl font-black text-white">Plugins</h1>
|
|
</div>
|
|
<p className="text-slate-500 text-sm ml-12">
|
|
{activeCount} de {plugins.length} ativos
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={fetchPlugins}
|
|
className="flex items-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white text-sm rounded-xl transition-colors"
|
|
>
|
|
<RefreshCw size={14} />
|
|
Atualizar
|
|
</button>
|
|
</div>
|
|
|
|
{/* Banner de saúde do storage — visível apenas quando há problema */}
|
|
{storageHealth && storageHealth.reason !== 'ok' && (() => {
|
|
const cfg = STORAGE_ALERT_CONFIG[storageHealth.reason]
|
|
return (
|
|
<div className={`flex items-start gap-3 px-4 py-3 rounded-xl border mb-6 ${cfg.color}`}>
|
|
<span className="text-lg font-bold shrink-0 mt-0.5">{cfg.icon}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-bold text-sm">{cfg.label}</p>
|
|
<p className="text-xs mt-0.5 opacity-80">{storageHealth.message}</p>
|
|
</div>
|
|
<button
|
|
onClick={fetchStorageHealth}
|
|
className="shrink-0 text-xs underline opacity-60 hover:opacity-100 transition-opacity mt-0.5"
|
|
>
|
|
Verificar novamente
|
|
</button>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-24">
|
|
<Loader2 size={32} className="text-brand-500 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{categories.map(cat => {
|
|
const catPlugins = plugins.filter(p => p.category === cat)
|
|
if (catPlugins.length === 0) return null
|
|
return (
|
|
<div key={cat}>
|
|
<h2 className="text-xs font-black text-slate-500 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<span className={`inline-block w-2 h-2 rounded-full ${cat === 'core' ? 'bg-blue-400' : cat === 'integration' ? 'bg-purple-400' : cat === 'business' ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
|
{CATEGORY_LABELS[cat]}
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{catPlugins.map(plugin => (
|
|
<PluginCard
|
|
key={plugin.name}
|
|
plugin={plugin}
|
|
onToggle={handleToggle}
|
|
onConfigure={setConfigPlugin}
|
|
toggling={toggling}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Config Modal */}
|
|
<AnimatePresence>
|
|
{configPlugin && (
|
|
configPlugin.name === 'UnifiedStorageProvider' ? (
|
|
<WasabiStorageModal
|
|
plugin={configPlugin}
|
|
onClose={() => setConfigPlugin(null)}
|
|
onSaved={() => {
|
|
showToast('Configuração salva com sucesso', 'ok')
|
|
fetchPlugins()
|
|
}}
|
|
/>
|
|
) : (
|
|
<ConfigModal
|
|
plugin={configPlugin}
|
|
onClose={() => setConfigPlugin(null)}
|
|
onSaved={() => {
|
|
showToast('Configuração salva com sucesso', 'ok')
|
|
fetchPlugins()
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Toast */}
|
|
<AnimatePresence>
|
|
{toast && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 20 }}
|
|
className={`fixed bottom-6 right-6 flex items-center gap-3 px-5 py-3.5 rounded-2xl shadow-2xl text-sm font-semibold z-[100] ${
|
|
toast.type === 'ok'
|
|
? 'bg-green-500/15 border border-green-500/30 text-green-300'
|
|
: 'bg-red-500/15 border border-red-500/30 text-red-300'
|
|
}`}
|
|
>
|
|
{toast.type === 'ok' ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
|
{toast.msg}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|