1087 lines
51 KiB
TypeScript
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;
|