Files
Papo/frontend/src/App.tsx
Pepe44DEV e9b0f25d71
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled
fix: HeroUI v3 compound API (Card.Content/Card.Header) + @heroui/styles CSS import
2026-07-01 04:34:39 +02:00

1087 lines
51 KiB
TypeScript

import {
Avatar,
Button,
Card,
Chip,
Input,
Spinner,
Switch,
Tab,
Tabs,
TextArea
} from '@heroui/react';
import {
Activity,
AudioLines,
Bot,
CalendarDays,
Cable,
ChevronRight,
Command,
Home,
LifeBuoy,
Logs,
MessageSquare,
Puzzle,
RadioTower,
ScanSearch,
Settings,
Shield,
Sparkles,
Tag,
Ticket,
UserRound,
Users,
Wrench
} from 'lucide-react';
import { useEffect, useState } from 'react';
type AppConfig = {
baseRoot?: string;
baseApi?: string;
baseAuth?: string;
baseDashboard?: string;
initialGuildId?: string;
};
type User = {
username: string;
discriminator?: string;
isAdmin?: boolean;
};
type Guild = {
id: string;
name: string;
icon?: string;
};
type NavKey =
| 'overview'
| 'tickets'
| 'automod'
| 'welcome'
| 'dynamicvoice'
| 'birthday'
| 'reactionroles'
| 'statuspage'
| 'serverstats'
| 'settings'
| 'modules'
| 'events'
| 'admin';
type TicketRecord = {
id: string;
topic?: string;
status?: string;
category?: string;
priority?: string;
createdAt?: string;
claimedById?: string | null;
};
type StatusService = {
id: string;
name?: string;
url?: string;
status?: string;
uptimePct?: number;
lastCheckedAt?: string;
};
type EventItem = {
id: string;
title: string;
description?: string;
startsAt?: string;
reminderMinutes?: number;
channelId?: string;
};
type ReactionRoleSet = {
id: string;
title?: string;
description?: string;
channelId?: string;
messageId?: string;
entries?: Array<{ emoji: string; roleId: string; label?: string; description?: string }>;
};
type ModuleItem = {
key: string;
name: string;
description?: string;
enabled: boolean;
};
type LogEntry = {
level?: string;
category?: string;
message?: string;
timestamp?: string;
};
type SettingsState = Record<string, any>;
const appConfig: AppConfig = (window as any).__PAPO__ || {};
const navItems: Array<{ key: NavKey; label: string; icon: JSX.Element }> = [
{ key: 'overview', label: 'Uebersicht', icon: <Home size={18} /> },
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={18} /> },
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={18} /> },
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={18} /> },
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'events', label: 'Events', icon: <CalendarDays size={18} /> },
{ key: 'admin', label: 'Admin', icon: <Wrench size={18} /> }
];
function apiUrl(path: string) {
const baseApi = appConfig.baseApi || '/api';
return `${baseApi}${path}`;
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(apiUrl(path), {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers || {})
}
});
if (response.status === 401) {
window.location.href = `${appConfig.baseAuth || '/auth'}/discord`;
throw new Error('unauthorized');
}
if (!response.ok) {
throw new Error(`request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
function formatDate(value?: string | number | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.toLocaleDateString('de-DE')} ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
function guildIconUrl(guild?: Guild | null) {
if (!guild) return undefined;
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
return undefined;
}
function sparkPath(kind: 'messages' | 'commands' | 'automod') {
if (kind === 'messages') return 'M4 26 C16 12, 24 10, 34 18 S54 30, 64 22 S80 6, 92 14 S108 20, 116 18';
if (kind === 'commands') return 'M4 22 C14 10, 22 12, 34 20 S54 28, 66 16 S82 8, 92 14 S108 24, 116 22';
return 'M4 28 C14 24, 22 10, 34 12 S54 30, 66 22 S84 8, 96 14 S110 18, 116 16';
}
function chartColor(kind: 'messages' | 'commands' | 'automod') {
if (kind === 'messages') return '#f2b36f';
if (kind === 'commands') return '#b26cff';
return '#48d883';
}
function App() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [guilds, setGuilds] = useState<Guild[]>([]);
const [currentGuildId, setCurrentGuildId] = useState(appConfig.initialGuildId || '');
const [section, setSection] = useState<NavKey>('overview');
const [guildInfo, setGuildInfo] = useState<any>(null);
const [overview, setOverview] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [pipeline, setPipeline] = useState<Record<string, TicketRecord[]>>({});
const [sla, setSla] = useState<any>({ supporters: [], days: [] });
const [automations, setAutomations] = useState<any[]>([]);
const [kbArticles, setKbArticles] = useState<any[]>([]);
const [settings, setSettings] = useState<SettingsState>({});
const [modules, setModules] = useState<ModuleItem[]>([]);
const [birthday, setBirthday] = useState<any>({ config: {}, birthdays: [] });
const [reactionRoles, setReactionRoles] = useState<ReactionRoleSet[]>([]);
const [statuspage, setStatuspage] = useState<any>({ services: [] });
const [serverStats, setServerStats] = useState<any>({ items: [] });
const [events, setEvents] = useState<EventItem[]>([]);
const [admin, setAdmin] = useState<any>({ overview: null, activity: null, logs: [] });
const [statusMessage, setStatusMessage] = useState('');
const [ticketTab, setTicketTab] = useState('overview');
const [automationDraft, setAutomationDraft] = useState({ name: '', conditionValue: '', actionValue: '' });
const [kbDraft, setKbDraft] = useState({ title: '', keywords: '', content: '' });
const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' });
const [statusDraft, setStatusDraft] = useState<any>(null);
const [statsDraft, setStatsDraft] = useState<any>(null);
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
if (navItems.some((item) => item.key === hash)) setSection(hash);
}, []);
useEffect(() => {
window.location.hash = section;
}, [section]);
useEffect(() => {
void bootstrap();
}, []);
useEffect(() => {
if (!currentGuildId) return;
void loadGuildData(currentGuildId);
}, [currentGuildId]);
async function bootstrap() {
try {
const me = await apiFetch<{ user: User }>('/me');
const guildRes = await apiFetch<{ guilds: Guild[] }>('/guilds');
setUser(me.user);
setGuilds(guildRes.guilds || []);
if (!currentGuildId && guildRes.guilds?.length) {
setCurrentGuildId(guildRes.guilds[0].id);
}
} finally {
setLoading(false);
}
}
async function loadGuildData(guildId: string) {
setStatusMessage('Lade Daten...');
try {
const [
guildInfoRes,
overviewRes,
activityRes,
logsRes,
settingsRes,
modulesRes,
birthdayRes,
reactionRes,
statusRes,
statsRes,
eventsRes
] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/logs?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/settings?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/modules?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/birthday?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/server-stats?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
setActivity(activityRes.activity || {});
setLogs(logsRes.logs || []);
setSettings(settingsRes.settings || {});
setModules(modulesRes.modules || []);
setBirthday(birthdayRes);
setReactionRoles(reactionRes.sets || []);
setStatuspage(statusRes.config || { services: [] });
setServerStats(statsRes.config || { items: [] });
setStatsDraft(statsRes.config || { items: [] });
setStatusDraft(statusRes.config || { services: [] });
setEvents(eventsRes.events || []);
setReactionDraft({
title: '',
channelId: '',
entries: ''
});
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
} catch {
setStatusMessage('Daten konnten nicht geladen werden');
}
}
async function loadTicketData(guildId: string) {
const [ticketRes, pipelineRes, slaRes, automationRes, kbRes] = await Promise.all([
apiFetch<any>(`/tickets?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/pipeline?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/sla?guildId=${encodeURIComponent(guildId)}&range=30`),
apiFetch<any>(`/automations?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/kb?guildId=${encodeURIComponent(guildId)}`)
]);
setTickets(ticketRes.tickets || []);
setPipeline(pipelineRes.pipeline || {});
setSla(slaRes || { supporters: [], days: [] });
setAutomations(automationRes.rules || []);
setKbArticles(kbRes.articles || []);
}
async function loadAdminData() {
if (!user?.isAdmin) return;
const [overviewRes, activityRes, logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({
overview: overviewRes,
activity: activityRes,
logs: logsRes.logs || []
});
}
async function saveSettingsPayload(payload: Record<string, any>, okMessage: string) {
if (!currentGuildId) return;
await apiFetch('/settings', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, ...payload })
});
setStatusMessage(okMessage);
await loadGuildData(currentGuildId);
}
async function saveBirthday() {
await apiFetch('/birthday', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
enabled: birthday.config?.enabled ?? true,
channelId: birthday.config?.channelId || '',
sendHour: birthday.config?.sendHour || 9,
messageTemplate: birthday.config?.messageTemplate || ''
})
});
setStatusMessage('Birthday gespeichert');
await loadGuildData(currentGuildId);
}
async function saveStatuspage() {
await apiFetch('/statuspage', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, config: statusDraft })
});
setStatusMessage('Statuspage gespeichert');
await loadGuildData(currentGuildId);
}
async function saveServerStats() {
await apiFetch('/server-stats', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, config: statsDraft })
});
setStatusMessage('Server Stats gespeichert');
await loadGuildData(currentGuildId);
}
async function saveEvent() {
if (!eventDraft.title) return;
await apiFetch('/events', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
title: eventDraft.title,
description: eventDraft.description,
channelId: eventDraft.channelId || undefined,
startsAt: eventDraft.startsAt || undefined
})
});
setEventDraft({ title: '', description: '', channelId: '', startsAt: '' });
await loadGuildData(currentGuildId);
setStatusMessage('Event gespeichert');
}
async function deleteEvent(id: string) {
await apiFetch(`/events/${id}`, {
method: 'DELETE',
body: JSON.stringify({ guildId: currentGuildId })
});
await loadGuildData(currentGuildId);
}
async function saveReactionRole() {
const entries = reactionDraft.entries
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split('|').map((part) => part.trim());
return {
emoji: parts[0],
roleId: parts[1],
label: parts[2] || undefined,
description: parts[3] || undefined
};
})
.filter((entry) => entry.emoji && entry.roleId);
await apiFetch('/reactionroles', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
channelId: reactionDraft.channelId,
title: reactionDraft.title,
entries
})
});
await loadGuildData(currentGuildId);
setStatusMessage('Reaction Role gespeichert');
}
async function toggleModule(key: string, enabled: boolean) {
await saveSettingsPayload({ [key]: enabled }, `${key} aktualisiert`);
}
async function saveAutomation() {
await apiFetch('/automations', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
name: automationDraft.name || 'Automation',
condition: { category: automationDraft.conditionValue },
action: { type: 'reminder', message: automationDraft.actionValue || 'Reminder' },
active: true
})
});
setAutomationDraft({ name: '', conditionValue: '', actionValue: '' });
await loadTicketData(currentGuildId);
}
async function saveKbArticle() {
await apiFetch('/kb', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
title: kbDraft.title || 'Artikel',
keywords: kbDraft.keywords,
content: kbDraft.content
})
});
setKbDraft({ title: '', keywords: '', content: '' });
await loadTicketData(currentGuildId);
}
const selectedGuild = guilds.find((guild) => guild.id === currentGuildId) || null;
const moduleFlags = guildInfo?.modules || {};
if (loading) {
return (
<div className="papo-shell flex items-center justify-center">
<Spinner color="warning" label="Dashboard wird geladen..." />
</div>
);
}
if (!selectedGuild) {
return (
<div className="papo-shell px-6 py-10 md:px-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="papo-section-title">Waehle einen Server</h1>
<p className="papo-section-subtitle">Die Guild-Auswahl laeuft jetzt ueber die neue HeroUI-Oberflaeche.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{guilds.map((guild) => (
<Card
key={guild.id}
isPressable
className="papo-card"
onPress={() => setCurrentGuildId(guild.id)}
>
<Card.Content className="flex flex-row items-center gap-4 p-5">
<Avatar src={guildIconUrl(guild)} name={guild.name} radius="lg" />
<div>
<div className="text-lg font-semibold">{guild.name}</div>
<div className="text-sm text-white/50">ID: {guild.id}</div>
</div>
</Card.Content>
</Card>
))}
</div>
</div>
</div>
);
}
return (
<div className="papo-shell">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="papo-sidebar flex flex-col gap-5 px-4 py-6">
<div className="flex items-center gap-3 px-2">
<div className="papo-logo">P</div>
<div>
<div className="text-2xl font-bold">Papo Control</div>
<div className="text-xs uppercase tracking-[0.22em] text-white/50">Guild Management</div>
</div>
</div>
<div className="flex flex-col gap-2">
{navItems
.filter((item) => (item.key === 'admin' ? user?.isAdmin : true))
.map((item) => (
<Button
key={item.key}
className="papo-nav-button justify-start border border-white/5 bg-white/[0.02] px-4 text-sm font-semibold text-white/70"
data-active={section === item.key}
radius="lg"
startContent={item.icon}
variant="flat"
onPress={() => setSection(item.key)}
>
{item.label}
</Button>
))}
</div>
<Card className="papo-card mt-auto">
<Card.Content className="gap-3 p-4">
<div className="text-xs uppercase tracking-[0.18em] text-white/45">Angemeldet als</div>
<div className="font-semibold">
{user?.username}
{user?.discriminator ? `#${user.discriminator}` : ''}
</div>
<Button
color="danger"
radius="lg"
startContent={<Logs size={16} />}
variant="flat"
onPress={() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}}
>
Logout
</Button>
</Card.Content>
</Card>
</aside>
<main className="px-4 py-6 md:px-8">
<div className="mx-auto max-w-[1520px]">
<Card className="papo-card mb-5">
<Card.Content className="flex flex-col gap-6 p-6 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="papo-section-title">Guild Dashboard</h1>
<p className="papo-section-subtitle">Komplettes HeroUI-Rework fuer dein Bot-Dashboard</p>
</div>
<div className="w-full max-w-sm">
<label className="block text-sm text-white/55">
Guild
<select
className="mt-2 w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none"
value={currentGuildId}
onChange={(event) => setCurrentGuildId(event.target.value)}
>
{guilds.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</label>
{statusMessage ? <div className="mt-2 text-sm text-warning-300">{statusMessage}</div> : null}
</div>
</Card.Content>
</Card>
{section === 'overview' && (
<div className="space-y-5">
<Card className="papo-card">
<Card.Content className="flex flex-col gap-5 p-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 items-center gap-4">
<Avatar className="h-20 w-20" radius="lg" src={guildIconUrl(selectedGuild)} />
<div className="min-w-0">
<div className="truncate text-3xl font-black">{guildInfo?.name || selectedGuild.name}</div>
<div className="mt-1 text-sm text-white/50">ID: {guildInfo?.id || selectedGuild.id}</div>
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(moduleFlags).map(([key, enabled]) => (
<Chip key={key} color={enabled ? 'warning' : 'default'} size="sm" variant="bordered">
{key.replace('Enabled', '').toUpperCase()}
</Chip>
))}
</div>
</div>
</div>
<Chip
color="success"
radius="sm"
startContent={<Bot size={14} />}
variant="dot"
>
Bot aktiv
</Chip>
</Card.Content>
</Card>
<div className="grid gap-5 xl:grid-cols-[1.05fr_1.05fr_1fr]">
<InfoPanel
title="Guild Infos"
actionLabel="Server Statistiken ansehen"
onAction={() => setSection('serverstats')}
items={[
{ icon: <UserRound size={18} />, label: 'Owner', value: guildInfo?.owner?.tag || '-' },
{ icon: <CalendarDays size={18} />, label: 'Erstellt', value: formatDate(guildInfo?.createdAt) },
{ icon: <Users size={18} />, label: 'Member', value: String(guildInfo?.memberCount ?? 0) },
{ icon: <Cable size={18} />, label: 'Channels', value: `${guildInfo?.textCount || 0} Text / ${guildInfo?.voiceCount || 0} Voice` },
{ icon: <Ticket size={18} />, label: 'Tickets offen', value: String(overview?.tickets?.open ?? 0) },
{ icon: <Shield size={18} />, label: 'Tickets IP / Closed', value: `${overview?.tickets?.inProgress ?? 0} / ${overview?.tickets?.closed ?? 0}` }
]}
/>
<Card className="papo-card">
<Card.Header className="px-5 pt-5 pb-0">
<div>
<h2 className="text-2xl font-bold">Activity</h2>
<p className="mt-1 text-sm text-white/50">Live-Statistiken aus deinem Bot</p>
</div>
</Card.Header>
<Card.Content className="gap-3 p-5">
<ActivityTile icon={<MessageSquare size={18} />} kind="messages" label="Messages (24h)" value={activity?.messages24h ?? 0} />
<ActivityTile icon={<Command size={18} />} kind="commands" label="Commands (24h)" value={activity?.commands24h ?? 0} />
<ActivityTile icon={<Shield size={18} />} kind="automod" label="Automod (24h)" value={activity?.automod24h ?? 0} />
<Button className="mt-2 justify-between" endContent={<ChevronRight size={16} />} variant="bordered" onPress={() => setSection('settings')}>
Alle Logs anzeigen
</Button>
</Card.Content>
</Card>
<Card className="papo-card">
<Card.Header className="flex items-center justify-between px-5 pt-5 pb-0">
<div>
<h2 className="text-2xl font-bold">Guild Logs</h2>
<p className="mt-1 text-sm text-white/50">Neueste Ereignisse</p>
</div>
<Button endContent={<ChevronRight size={16} />} size="sm" variant="light" onPress={() => setSection('settings')}>
Alle anzeigen
</Button>
</Card.Header>
<Card.Content className="p-5">
<div className="papo-scroll max-h-[360px] space-y-3 overflow-auto pr-2">
{logs.length ? (
logs.map((log, index) => (
<Card key={`${log.timestamp}-${index}`} className="border border-white/5 bg-white/[0.03]">
<Card.Content className="gap-2 p-4">
<div className="flex items-center gap-2">
<Chip color={log.level === 'warn' ? 'warning' : log.level === 'error' ? 'danger' : 'default'} size="sm" variant="bordered">
{(log.level || 'info').toUpperCase()}
</Chip>
<span className="text-xs text-white/45">{formatDate(log.timestamp)}</span>
</div>
<div className="text-sm text-white/80">{log.category ? `[${log.category}] ` : ''}{log.message || '-'}</div>
</Card.Content>
</Card>
))
) : (
<div className="text-sm text-white/45">Keine Logs</div>
)}
</div>
</Card.Content>
</Card>
</div>
<Card className="papo-card">
<Card.Header className="px-5 pt-5 pb-0">
<div>
<h2 className="text-2xl font-bold">Schnellzugriff</h2>
<p className="mt-1 text-sm text-white/50">Die wichtigsten Bereiche deines Dashboards</p>
</div>
</Card.Header>
<Card.Content className="grid gap-4 p-5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{navItems
.filter((item) => !['overview', 'admin'].includes(item.key))
.slice(0, 6)
.map((item) => (
<Card key={item.key} isPressable className="border border-white/6 bg-white/[0.03]" onPress={() => setSection(item.key)}>
<Card.Content className="gap-3 p-4">
<div className="papo-icon-badge">{item.icon}</div>
<div className="font-semibold">{item.label}</div>
<div className="text-sm text-white/45">HeroUI-Komponenten fuer den Bereich {item.label}</div>
</Card.Content>
</Card>
))}
</Card.Content>
</Card>
</div>
)}
{section === 'tickets' && (
<SectionCard title="Ticketsystem" subtitle="Ticket-Übersicht, Pipeline, SLA, Automationen und Knowledge Base.">
<Tabs
aria-label="Ticket Tabs"
color="warning"
selectedKey={ticketTab}
variant="bordered"
onSelectionChange={(key) => setTicketTab(String(key))}
>
<Tab key="overview" title="Übersicht" />
<Tab key="pipeline" title="Pipeline" />
<Tab key="sla" title="SLA" />
<Tab key="automations" title="Automationen" />
<Tab key="kb" title="Knowledge Base" />
</Tabs>
{ticketTab === 'overview' && (
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<ListCard title="Offene Tickets" items={tickets.map((ticket) => `${ticket.topic || 'Ticket'} · ${ticket.status || 'open'}`)} />
<ListCard title="Support-Übersicht" items={tickets.map((ticket) => `${ticket.category || 'Allgemein'} · ${formatDate(ticket.createdAt)}`)} />
</div>
)}
{ticketTab === 'pipeline' && (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
{['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].map((state) => (
<ListCard
key={state}
title={state.replaceAll('_', ' ')}
items={(pipeline[state] || []).map((ticket) => ticket.topic || ticket.id)}
/>
))}
</div>
)}
{ticketTab === 'sla' && (
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<ListCard
title="SLA pro Supporter"
items={(sla.supporters || []).map((row: any) => `${row.supporter || '-'} · ${row.tickets || 0} Tickets · ${row.ttc || '-'} TTC · ${row.ttfr || '-'} TTFR`)}
/>
<ListCard
title="SLA pro Tag"
items={(sla.days || []).map((row: any) => `${row.date || '-'} · ${row.tickets || 0} Tickets · ${row.ttc || '-'} TTC · ${row.ttfr || '-'} TTFR`)}
/>
</div>
)}
{ticketTab === 'automations' && (
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
<ListCard title="Regeln" items={automations.map((rule) => `${rule.name || 'Automation'} · ${rule.active === false ? 'inaktiv' : 'aktiv'}`)} />
<FormCard title="Neue Automation">
<Input label="Name" value={automationDraft.name} onValueChange={(value) => setAutomationDraft((state) => ({ ...state, name: value }))} />
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(value) => setAutomationDraft((state) => ({ ...state, conditionValue: value }))} />
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(value) => setAutomationDraft((state) => ({ ...state, actionValue: value }))} />
<Button color="warning" onPress={saveAutomation}>Automation speichern</Button>
</FormCard>
</div>
)}
{ticketTab === 'kb' && (
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
<ListCard title="Artikel" items={kbArticles.map((article) => `${article.title || 'Artikel'} · ${(article.keywords || []).join(', ')}`)} />
<FormCard title="Neuer KB-Artikel">
<Input label="Titel" value={kbDraft.title} onValueChange={(value) => setKbDraft((state) => ({ ...state, title: value }))} />
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(value) => setKbDraft((state) => ({ ...state, keywords: value }))} />
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(value) => setKbDraft((state) => ({ ...state, content: value }))} />
<Button color="warning" onPress={saveKbArticle}>Artikel speichern</Button>
</FormCard>
</div>
)}
</SectionCard>
)}
{section === 'automod' && (
<SettingsLayout
title="Automod"
subtitle="Filter, Logging und Sicherheit in HeroUI-Formularen."
onSave={() =>
saveSettingsPayload(
{
automodEnabled: settings.automodEnabled !== false,
automodConfig: settings.automodConfig || {}
},
'Automod gespeichert'
)
}
>
<Switch isSelected={settings.automodEnabled !== false} onValueChange={(value) => setSettings((state) => ({ ...state, automodEnabled: value }))}>Automod aktiv</Switch>
<Switch isSelected={settings.automodConfig?.badWordFilter ?? false} onValueChange={(value) => setSettings((state) => ({ ...state, automodConfig: { ...(state.automodConfig || {}), badWordFilter: value } }))}>Bad-Word-Filter</Switch>
<Switch isSelected={settings.automodConfig?.linkFilter ?? false} onValueChange={(value) => setSettings((state) => ({ ...state, automodConfig: { ...(state.automodConfig || {}), linkFilter: value } }))}>Link-Filter</Switch>
<Switch isSelected={settings.automodConfig?.spamFilter ?? false} onValueChange={(value) => setSettings((state) => ({ ...state, automodConfig: { ...(state.automodConfig || {}), spamFilter: value } }))}>Spam-Filter</Switch>
<Input label="Log Channel ID" value={settings.automodConfig?.logChannelId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, automodConfig: { ...(state.automodConfig || {}), logChannelId: value } }))} />
<TextArea label="Whitelist Links (Komma-getrennt)" value={(settings.automodConfig?.linkWhitelist || []).join(', ')} onValueChange={(value) => setSettings((state) => ({ ...state, automodConfig: { ...(state.automodConfig || {}), linkWhitelist: value.split(',').map((item) => item.trim()).filter(Boolean) } }))} />
</SettingsLayout>
)}
{section === 'welcome' && (
<SettingsLayout
title="Willkommen"
subtitle="Welcome-Embeds und Join-Nachrichten."
onSave={() => saveSettingsPayload({ welcomeEnabled: settings.welcomeConfig?.enabled !== false, welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}
>
<Switch isSelected={settings.welcomeConfig?.enabled !== false} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeConfig: { ...(state.welcomeConfig || {}), enabled: value } }))}>Welcome aktiv</Switch>
<Input label="Channel ID" value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeConfig: { ...(state.welcomeConfig || {}), channelId: value } }))} />
<Input label="Titel" value={settings.welcomeConfig?.embedTitle || ''} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeConfig: { ...(state.welcomeConfig || {}), embedTitle: value } }))} />
<TextArea label="Beschreibung" value={settings.welcomeConfig?.embedDescription || ''} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeConfig: { ...(state.welcomeConfig || {}), embedDescription: value } }))} />
<Input label="Footer" value={settings.welcomeConfig?.embedFooter || ''} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeConfig: { ...(state.welcomeConfig || {}), embedFooter: value } }))} />
</SettingsLayout>
)}
{section === 'dynamicvoice' && (
<SettingsLayout
title="Dynamic Voice"
subtitle="Voice-Lobby, Template und Limits."
onSave={() => saveSettingsPayload({ dynamicVoiceEnabled: settings.dynamicVoiceEnabled !== false, dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}
>
<Switch isSelected={settings.dynamicVoiceEnabled !== false} onValueChange={(value) => setSettings((state) => ({ ...state, dynamicVoiceEnabled: value }))}>Dynamic Voice aktiv</Switch>
<Input label="Lobby Channel ID" value={settings.dynamicVoiceConfig?.lobbyChannelId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, dynamicVoiceConfig: { ...(state.dynamicVoiceConfig || {}), lobbyChannelId: value } }))} />
<Input label="Kategorie ID" value={settings.dynamicVoiceConfig?.categoryId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, dynamicVoiceConfig: { ...(state.dynamicVoiceConfig || {}), categoryId: value } }))} />
<Input label="Template" value={settings.dynamicVoiceConfig?.template || ''} onValueChange={(value) => setSettings((state) => ({ ...state, dynamicVoiceConfig: { ...(state.dynamicVoiceConfig || {}), template: value } }))} />
</SettingsLayout>
)}
{section === 'birthday' && (
<SettingsLayout title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Einträge." onSave={saveBirthday}>
<Switch isSelected={birthday.config?.enabled !== false} onValueChange={(value) => setBirthday((state: any) => ({ ...state, config: { ...state.config, enabled: value } }))}>Birthday aktiv</Switch>
<Input label="Channel ID" value={birthday.config?.channelId || ''} onValueChange={(value) => setBirthday((state: any) => ({ ...state, config: { ...state.config, channelId: value } }))} />
<Input label="Sendezeit (Stunde)" type="number" value={String(birthday.config?.sendHour ?? 9)} onValueChange={(value) => setBirthday((state: any) => ({ ...state, config: { ...state.config, sendHour: Number(value || 0) } }))} />
<TextArea label="Template" value={birthday.config?.messageTemplate || ''} onValueChange={(value) => setBirthday((state: any) => ({ ...state, config: { ...state.config, messageTemplate: value } }))} />
<ListCard title="Gespeicherte Geburtstage" items={(birthday.birthdays || []).map((entry: any) => `${String(entry.birthDate || '').replace(/^--/, '')} · ${entry.userId}`)} />
</SettingsLayout>
)}
{section === 'reactionroles' && (
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen.">
<div className="grid gap-4 xl:grid-cols-[1fr_420px]">
<ListCard title="Bestehende Sets" items={reactionRoles.map((set) => `${set.title || 'Reaction Role'} · ${set.channelId || '-'}`)} />
<FormCard title="Neues Set">
<Input label="Titel" value={reactionDraft.title} onValueChange={(value) => setReactionDraft((state) => ({ ...state, title: value }))} />
<Input label="Channel ID" value={reactionDraft.channelId} onValueChange={(value) => setReactionDraft((state) => ({ ...state, channelId: value }))} />
<TextArea label="Einträge (Emoji | Role ID | Label | Beschreibung)" minRows={6} value={reactionDraft.entries} onValueChange={(value) => setReactionDraft((state) => ({ ...state, entries: value }))} />
<Button color="warning" onPress={saveReactionRole}>Reaction Role speichern</Button>
</FormCard>
</div>
</SectionCard>
)}
{section === 'statuspage' && (
<SectionCard title="Statuspage" subtitle="Statusseite und Service-Liste verwalten.">
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
<FormCard title="Konfiguration">
<Switch isSelected={statusDraft?.enabled !== false} onValueChange={(value) => setStatusDraft((state: any) => ({ ...(state || {}), enabled: value }))}>Statuspage aktiv</Switch>
<Input label="Channel ID" value={statusDraft?.channelId || ''} onValueChange={(value) => setStatusDraft((state: any) => ({ ...(state || {}), channelId: value }))} />
<Input label="Intervall (ms)" value={String(statusDraft?.intervalMs || 60000)} onValueChange={(value) => setStatusDraft((state: any) => ({ ...(state || {}), intervalMs: Number(value || 60000) }))} />
<Button color="warning" onPress={saveStatuspage}>Statuspage speichern</Button>
</FormCard>
<ListCard
title="Services"
items={((statusDraft?.services || statuspage?.services || []) as StatusService[]).map((service) => `${service.name || 'Service'} · ${service.status || 'unknown'} · ${service.url || ''}`)}
/>
</div>
</SectionCard>
)}
{section === 'serverstats' && (
<SectionCard title="Server Stats" subtitle="Counter und Refresh-Intervall steuern.">
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
<FormCard title="Konfiguration">
<Switch isSelected={statsDraft?.enabled === true} onValueChange={(value) => setStatsDraft((state: any) => ({ ...(state || {}), enabled: value }))}>Server Stats aktiv</Switch>
<Input label="Kategorie-Name" value={statsDraft?.categoryName || ''} onValueChange={(value) => setStatsDraft((state: any) => ({ ...(state || {}), categoryName: value }))} />
<Input label="Refresh (Minuten)" value={String(statsDraft?.refreshMinutes || 10)} onValueChange={(value) => setStatsDraft((state: any) => ({ ...(state || {}), refreshMinutes: Number(value || 10) }))} />
<Button color="warning" onPress={saveServerStats}>Server Stats speichern</Button>
</FormCard>
<ListCard title="Items" items={((statsDraft?.items || serverStats?.items || []) as any[]).map((item) => `${item.label || item.key || 'Stat'} · ${item.type || '-'}`)} />
</div>
</SectionCard>
)}
{section === 'settings' && (
<SettingsLayout title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien." onSave={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
<Input label="Welcome Channel ID" value={settings.welcomeChannelId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, welcomeChannelId: value }))} />
<Input label="Log Channel ID" value={settings.logChannelId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, logChannelId: value }))} />
<Input label="Support Role ID" value={settings.supportRoleId || ''} onValueChange={(value) => setSettings((state) => ({ ...state, supportRoleId: value }))} />
<Switch isSelected={settings.loggingConfig?.categories?.joinLeave !== false} onValueChange={(value) => setSettings((state) => ({ ...state, loggingConfig: { ...(state.loggingConfig || {}), categories: { ...(state.loggingConfig?.categories || {}), joinLeave: value } } }))}>Join / Leave loggen</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.messageEdit !== false} onValueChange={(value) => setSettings((state) => ({ ...state, loggingConfig: { ...(state.loggingConfig || {}), categories: { ...(state.loggingConfig?.categories || {}), messageEdit: value } } }))}>Message Edit loggen</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.messageDelete !== false} onValueChange={(value) => setSettings((state) => ({ ...state, loggingConfig: { ...(state.loggingConfig || {}), categories: { ...(state.loggingConfig?.categories || {}), messageDelete: value } } }))}>Message Delete loggen</Switch>
</SettingsLayout>
)}
{section === 'modules' && (
<SectionCard title="Module" subtitle="Module direkt in HeroUI umschalten.">
<div className="grid gap-3 xl:grid-cols-2">
{modules.map((module) => (
<Card key={module.key} className="border border-white/6 bg-white/[0.03]">
<Card.Content className="flex flex-row items-center justify-between gap-4 p-4">
<div>
<div className="font-semibold">{module.name}</div>
<div className="text-sm text-white/45">{module.description || ''}</div>
</div>
<Switch isSelected={module.enabled} onValueChange={(value) => void toggleModule(module.key, value)} />
</Card.Content>
</Card>
))}
</div>
</SectionCard>
)}
{section === 'events' && (
<SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow.">
<div className="grid gap-4 xl:grid-cols-[1fr_420px]">
<div className="space-y-3">
{(events || []).map((event) => (
<Card key={event.id} className="border border-white/6 bg-white/[0.03]">
<Card.Content className="gap-2 p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold">{event.title}</div>
<Button color="danger" size="sm" variant="flat" onPress={() => deleteEvent(event.id)}>Löschen</Button>
</div>
<div className="text-sm text-white/45">{event.description || 'Keine Beschreibung'}</div>
<div className="text-xs text-white/35">{formatDate(event.startsAt)}</div>
</Card.Content>
</Card>
))}
</div>
<FormCard title="Neues Event">
<Input label="Titel" value={eventDraft.title} onValueChange={(value) => setEventDraft((state) => ({ ...state, title: value }))} />
<TextArea label="Beschreibung" value={eventDraft.description} onValueChange={(value) => setEventDraft((state) => ({ ...state, description: value }))} />
<Input label="Channel ID" value={eventDraft.channelId} onValueChange={(value) => setEventDraft((state) => ({ ...state, channelId: value }))} />
<Input label="Start (ISO / Text)" value={eventDraft.startsAt} onValueChange={(value) => setEventDraft((state) => ({ ...state, startsAt: value }))} />
<Button color="warning" onPress={saveEvent}>Event speichern</Button>
</FormCard>
</div>
</SectionCard>
)}
{section === 'admin' && user?.isAdmin && (
<SectionCard title="Admin" subtitle="Bot-weite Übersichten fuer Admins.">
<div className="grid gap-4 xl:grid-cols-2">
<ListCard title="Bot Overview" items={[`Guilds: ${admin.overview?.guilds ?? '-'}`, `Aktive Guilds (24h): ${admin.overview?.activeGuilds ?? '-'}`, `Uptime: ${admin.overview?.uptime ?? '-'}`]} />
<ListCard title="Letzte Admin Logs" items={(admin.logs || []).map((log: any) => `${formatDate(log.timestamp)} · ${log.message || '-'}`)} />
</div>
</SectionCard>
)}
</div>
</main>
</div>
</div>
);
}
function SectionCard(props: { title: string; subtitle: string; children: React.ReactNode }) {
return (
<Card className="papo-card">
<Card.Header className="px-5 pt-5 pb-0">
<div>
<h2 className="text-2xl font-bold">{props.title}</h2>
<p className="mt-1 text-sm text-white/50">{props.subtitle}</p>
</div>
</Card.Header>
<Card.Content className="p-5">{props.children}</Card.Content>
</Card>
);
}
function FormCard(props: { title: string; children: React.ReactNode }) {
return (
<Card className="border border-white/6 bg-white/[0.03]">
<Card.Header className="px-5 pt-5 pb-0">
<div className="text-lg font-semibold">{props.title}</div>
</Card.Header>
<Card.Content className="gap-4 p-5">{props.children}</Card.Content>
</Card>
);
}
function ListCard(props: { title: string; items: string[] }) {
return (
<Card className="border border-white/6 bg-white/[0.03]">
<Card.Header className="px-5 pt-5 pb-0">
<div className="text-lg font-semibold">{props.title}</div>
</Card.Header>
<Card.Content className="p-5">
<div className="papo-scroll max-h-[460px] space-y-3 overflow-auto pr-1">
{props.items.length ? props.items.map((item, index) => <div key={`${item}-${index}`} className="rounded-xl border border-white/6 bg-white/[0.02] px-4 py-3 text-sm text-white/75">{item}</div>) : <div className="text-sm text-white/45">Keine Daten</div>}
</div>
</Card.Content>
</Card>
);
}
function InfoPanel(props: { title: string; items: Array<{ icon: React.ReactNode; label: string; value: string }>; actionLabel: string; onAction: () => void }) {
return (
<Card className="papo-card">
<Card.Header className="px-5 pt-5 pb-0">
<div>
<h2 className="text-2xl font-bold">{props.title}</h2>
<p className="mt-1 text-sm text-white/50">Wichtige Guild-Daten auf einen Blick</p>
</div>
</Card.Header>
<Card.Content className="gap-4 p-5">
<div className="grid gap-3 md:grid-cols-2">
{props.items.map((item) => (
<Card key={item.label} className="border border-white/6 bg-white/[0.03]">
<Card.Content className="gap-3 p-4">
<div className="flex items-center gap-2 text-sm uppercase tracking-[0.14em] text-white/45">
<span className="text-warning-300">{item.icon}</span>
{item.label}
</div>
<div className="text-xl font-bold">{item.value}</div>
</Card.Content>
</Card>
))}
</div>
<Button className="justify-between" endContent={<ChevronRight size={16} />} variant="bordered" onPress={props.onAction}>
{props.actionLabel}
</Button>
</Card.Content>
</Card>
);
}
function ActivityTile(props: { icon: React.ReactNode; label: string; value: number; kind: 'messages' | 'commands' | 'automod' }) {
return (
<Card className="border border-white/6 bg-white/[0.03]">
<Card.Content className="flex flex-row items-center justify-between gap-4 p-4">
<div className="flex items-center gap-3">
<div className="papo-icon-badge">{props.icon}</div>
<div>
<div className="text-sm uppercase tracking-[0.14em] text-white/45">{props.label}</div>
<div className="text-2xl font-black">{props.value}</div>
</div>
</div>
<svg className="papo-chart" viewBox="0 0 120 40">
<path d={sparkPath(props.kind)} stroke={chartColor(props.kind)} />
</svg>
</Card.Content>
</Card>
);
}
function SettingsLayout(props: { title: string; subtitle: string; children: React.ReactNode; onSave: () => void | Promise<void> }) {
return (
<SectionCard title={props.title} subtitle={props.subtitle}>
<div className="grid gap-4 xl:grid-cols-2">
<FormCard title={`${props.title} konfigurieren`}>
{props.children}
<div className="my-2 h-px w-full bg-white/6" />
<Button color="warning" onPress={() => void props.onSave()}>
Speichern
</Button>
</FormCard>
<Card className="border border-white/6 bg-white/[0.03]">
<Card.Content className="items-start gap-4 p-5">
<div className="text-lg font-semibold">Design-Richtung</div>
<p className="text-sm text-white/55">
Dieser Bereich nutzt jetzt dieselbe HeroUI-Oberfläche wie dein Overview-Dashboard.
Die tieferen Spezial-Workflows aus dem alten Inline-Dashboard werden hier schrittweise in echte React-Komponenten überführt.
</p>
</Card.Content>
</Card>
</div>
</SectionCard>
);
}
export default App;