diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7021c5..ec8682c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,198 +1,32 @@ +import { useEffect, useState } from 'react'; import { - Avatar, - Button, - Card, - Chip, - Input, - Spinner, - Switch, - Tab, - Tabs, - TextArea + Alert, Avatar, Button, Card, CardContent, CardHeader, Chip, Divider, + Input, Select, SelectItem, Skeleton, 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 + Activity, AudioLines, Bot, Cable, CalendarDays, 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; +import { apiFetch } from './utils/api'; +import { formatDate, guildIconUrl } from './utils/formatters'; +import { navItems } from './utils/constants'; +import { Sidebar } from './components/layout/Sidebar'; +import { Topbar } from './components/layout/Topbar'; +import { SectionCard } from './components/shared/SectionCard'; +import { FormPanel } from './components/shared/FormPanel'; +import { ListPanel } from './components/shared/ListPanel'; +import { StatCard } from './components/shared/StatCard'; +import { ActivityTile } from './components/shared/ActivityTile'; +import { LoadingSkeleton } from './components/shared/LoadingSkeleton'; +import type { + AppConfig, User, Guild, NavKey, TicketRecord, StatusService, + EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState +} from './types'; const appConfig: AppConfig = (window as any).__PAPO__ || {}; -const navItems: Array<{ key: NavKey; label: string; icon: JSX.Element }> = [ - { key: 'overview', label: 'Uebersicht', icon: }, - { key: 'tickets', label: 'Ticketsystem', icon: }, - { key: 'automod', label: 'Automod', icon: }, - { key: 'welcome', label: 'Willkommen', icon: }, - { key: 'dynamicvoice', label: 'Dynamic Voice', icon: }, - { key: 'birthday', label: 'Birthday', icon: }, - { key: 'reactionroles', label: 'Reaction Roles', icon: }, - { key: 'statuspage', label: 'Statuspage', icon: }, - { key: 'serverstats', label: 'Server Stats', icon: }, - { key: 'settings', label: 'Einstellungen', icon: }, - { key: 'modules', label: 'Module', icon: }, - { key: 'events', label: 'Events', icon: }, - { key: 'admin', label: 'Admin', icon: } -]; - -function apiUrl(path: string) { - const baseApi = appConfig.baseApi || '/api'; - return `${baseApi}${path}`; -} - -async function apiFetch(path: string, init?: RequestInit): Promise { - 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; -} - -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(null); @@ -230,18 +64,9 @@ function App() { 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]); + useEffect(() => { window.location.hash = section; }, [section]); + useEffect(() => { void bootstrap(); }, []); + useEffect(() => { if (currentGuildId) void loadGuildData(currentGuildId); }, [currentGuildId]); async function bootstrap() { try { @@ -249,30 +74,15 @@ function App() { 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); - } + 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([ + const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes, + birthdayRes, reactionRes, statusRes, statsRes, eventsRes] = await Promise.all([ apiFetch(`/guild/info?guildId=${encodeURIComponent(guildId)}`), apiFetch(`/overview?guildId=${encodeURIComponent(guildId)}`), apiFetch(`/guild/activity?guildId=${encodeURIComponent(guildId)}`), @@ -285,7 +95,6 @@ function App() { apiFetch(`/server-stats?guildId=${encodeURIComponent(guildId)}`), apiFetch(`/events?guildId=${encodeURIComponent(guildId)}`) ]); - setGuildInfo(guildInfoRes.guild || null); setOverview(overviewRes); setActivity(activityRes.activity || {}); @@ -299,18 +108,10 @@ function App() { setStatsDraft(statsRes.config || { items: [] }); setStatusDraft(statusRes.config || { services: [] }); setEvents(eventsRes.events || []); - - setReactionDraft({ - title: '', - channelId: '', - entries: '' - }); - + setReactionDraft({ title: '', channelId: '', entries: '' }); await Promise.all([loadTicketData(guildId), loadAdminData()]); setStatusMessage(''); - } catch { - setStatusMessage('Daten konnten nicht geladen werden'); - } + } catch { setStatusMessage('Daten konnten nicht geladen werden'); } } async function loadTicketData(guildId: string) { @@ -321,7 +122,6 @@ function App() { apiFetch(`/automations?guildId=${encodeURIComponent(guildId)}`), apiFetch(`/kb?guildId=${encodeURIComponent(guildId)}`) ]); - setTickets(ticketRes.tickets || []); setPipeline(pipelineRes.pipeline || {}); setSla(slaRes || { supporters: [], days: [] }); @@ -331,25 +131,17 @@ function App() { async function loadAdminData() { if (!user?.isAdmin) return; - const [overviewRes, activityRes, logsRes] = await Promise.all([ + const [overviewRes, , logsRes] = await Promise.all([ apiFetch('/admin/overview'), apiFetch('/admin/activity'), apiFetch('/admin/logs') ]); - - setAdmin({ - overview: overviewRes, - activity: activityRes, - logs: logsRes.logs || [] - }); + setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] }); } async function saveSettingsPayload(payload: Record, okMessage: string) { if (!currentGuildId) return; - await apiFetch('/settings', { - method: 'POST', - body: JSON.stringify({ guildId: currentGuildId, ...payload }) - }); + await apiFetch('/settings', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, ...payload }) }); setStatusMessage(okMessage); await loadGuildData(currentGuildId); } @@ -370,19 +162,13 @@ function App() { } async function saveStatuspage() { - await apiFetch('/statuspage', { - method: 'POST', - body: JSON.stringify({ guildId: currentGuildId, config: statusDraft }) - }); + 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 }) - }); + await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: statsDraft }) }); setStatusMessage('Server Stats gespeichert'); await loadGuildData(currentGuildId); } @@ -392,10 +178,8 @@ function App() { await apiFetch('/events', { method: 'POST', body: JSON.stringify({ - guildId: currentGuildId, - title: eventDraft.title, - description: eventDraft.description, - channelId: eventDraft.channelId || undefined, + guildId: currentGuildId, title: eventDraft.title, + description: eventDraft.description, channelId: eventDraft.channelId || undefined, startsAt: eventDraft.startsAt || undefined }) }); @@ -405,39 +189,15 @@ function App() { } async function deleteEvent(id: string) { - await apiFetch(`/events/${id}`, { - method: 'DELETE', - body: JSON.stringify({ guildId: currentGuildId }) - }); + 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 - }) - }); - + const entries = reactionDraft.entries.split('\n').map((l) => l.trim()).filter(Boolean) + .map((line) => { const p = line.split('|').map((s) => s.trim()); return { emoji: p[0], roleId: p[1], label: p[2], description: p[3] }; }) + .filter((e) => e.emoji && e.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'); } @@ -450,11 +210,9 @@ function App() { await apiFetch('/automations', { method: 'POST', body: JSON.stringify({ - guildId: currentGuildId, - name: automationDraft.name || 'Automation', + guildId: currentGuildId, name: automationDraft.name || 'Automation', condition: { category: automationDraft.conditionValue }, - action: { type: 'reminder', message: automationDraft.actionValue || 'Reminder' }, - active: true + action: { type: 'reminder', message: automationDraft.actionValue || 'Reminder' }, active: true }) }); setAutomationDraft({ name: '', conditionValue: '', actionValue: '' }); @@ -464,623 +222,510 @@ function App() { async function saveKbArticle() { await apiFetch('/kb', { method: 'POST', - body: JSON.stringify({ - guildId: currentGuildId, - title: kbDraft.title || 'Artikel', - keywords: kbDraft.keywords, - content: kbDraft.content - }) + 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 selectedGuild = guilds.find((g) => g.id === currentGuildId) || null; const moduleFlags = guildInfo?.modules || {}; + function handleLogout() { + window.location.href = `${appConfig.baseAuth || '/auth'}/logout`; + } + if (loading) { return ( -
- +
+
); } if (!selectedGuild) { return ( -
-
-
-

Waehle einen Server

-

Die Guild-Auswahl laeuft jetzt ueber die neue HeroUI-Oberflaeche.

-
-
- {guilds.map((guild) => ( - setCurrentGuildId(guild.id)} - > - - -
-
{guild.name}
-
ID: {guild.id}
-
-
-
- ))} -
+
+

Wähle einen Server

+

Wähle einen Discord-Server aus, um das Dashboard zu öffnen.

+
+ {guilds.map((guild) => ( + setCurrentGuildId(guild.id)}> + + +
+
{guild.name}
+
ID: {guild.id}
+
+
+
+ ))}
); } return ( -
-
- - -
-
- - -
-

Guild Dashboard

-

Komplettes HeroUI-Rework fuer dein Bot-Dashboard

-
-
- - {statusMessage ?
{statusMessage}
: null} -
-
-
- - {section === 'overview' && ( -
- - -
- -
-
{guildInfo?.name || selectedGuild.name}
-
ID: {guildInfo?.id || selectedGuild.id}
-
- {Object.entries(moduleFlags).map(([key, enabled]) => ( - - {key.replace('Enabled', '').toUpperCase()} - - ))} -
+ {section === 'overview' && ( +
+ + +
+ +
+
{guildInfo?.name || selectedGuild.name}
+
ID: {guildInfo?.id || selectedGuild.id}
+
+ {Object.entries(moduleFlags).map(([key, enabled]) => ( + + {key.replace('Enabled', '').toUpperCase()} + + ))}
- } - variant="dot" - > - Bot aktiv - - +
+ } variant="dot"> + Bot aktiv + +
+
+ +
+ + +

Guild Infos

+

Wichtige Daten auf einen Blick

+
+ +
+ } label="Owner" value={guildInfo?.owner?.tag || '-'} /> + } label="Erstellt" value={formatDate(guildInfo?.createdAt)} /> + } label="Member" value={String(guildInfo?.memberCount ?? 0)} /> + } label="Channels" value={`${guildInfo?.textCount || 0} / ${guildInfo?.voiceCount || 0}`} /> + } label="Offene Tickets" value={String(overview?.tickets?.open ?? 0)} /> + } label="IP / Closed" value={`${overview?.tickets?.inProgress ?? 0} / ${overview?.tickets?.closed ?? 0}`} /> +
+ +
-
- setSection('serverstats')} - items={[ - { icon: , label: 'Owner', value: guildInfo?.owner?.tag || '-' }, - { icon: , label: 'Erstellt', value: formatDate(guildInfo?.createdAt) }, - { icon: , label: 'Member', value: String(guildInfo?.memberCount ?? 0) }, - { icon: , label: 'Channels', value: `${guildInfo?.textCount || 0} Text / ${guildInfo?.voiceCount || 0} Voice` }, - { icon: , label: 'Tickets offen', value: String(overview?.tickets?.open ?? 0) }, - { icon: , label: 'Tickets IP / Closed', value: `${overview?.tickets?.inProgress ?? 0} / ${overview?.tickets?.closed ?? 0}` } - ]} - /> + + +

Activity

+

Live-Statistiken aus deinem Bot

+
+ + } label="Messages (24h)" value={activity?.messages24h ?? 0} /> + } label="Commands (24h)" value={activity?.commands24h ?? 0} /> + } label="Automod (24h)" value={activity?.automod24h ?? 0} /> + + +
- - -
-

Activity

-

Live-Statistiken aus deinem Bot

-
-
- - } kind="messages" label="Messages (24h)" value={activity?.messages24h ?? 0} /> - } kind="commands" label="Commands (24h)" value={activity?.commands24h ?? 0} /> - } kind="automod" label="Automod (24h)" value={activity?.automod24h ?? 0} /> - - -
- - - -
-

Guild Logs

-

Neueste Ereignisse

-
- -
- -
- {logs.length ? ( - logs.map((log, index) => ( - - -
- - {(log.level || 'info').toUpperCase()} - - {formatDate(log.timestamp)} -
-
{log.category ? `[${log.category}] ` : ''}{log.message || '-'}
-
-
- )) - ) : ( -
Keine Logs
- )} -
-
-
-
- - - + +
-

Schnellzugriff

-

Die wichtigsten Bereiche deines Dashboards

+

Guild Logs

+

Neueste Ereignisse

-
- - {navItems - .filter((item) => !['overview', 'admin'].includes(item.key)) - .slice(0, 6) - .map((item) => ( - setSection(item.key)}> - -
{item.icon}
-
{item.label}
-
HeroUI-Komponenten fuer den Bereich {item.label}
-
+ + + +
+ {logs.length ? logs.map((log, i) => ( + + +
+ + {(log.level || 'info').toUpperCase()} + + {formatDate(log.timestamp)} +
+
{log.category ? `[${log.category}] ` : ''}{log.message || '-'}
+
- ))} - + )) : ( +

Keine Logs

+ )} +
+
- )} - {section === 'tickets' && ( - - setTicketTab(String(key))} - > - - - - - - - - {ticketTab === 'overview' && ( -
- `${ticket.topic || 'Ticket'} · ${ticket.status || 'open'}`)} /> - `${ticket.category || 'Allgemein'} · ${formatDate(ticket.createdAt)}`)} /> -
- )} - - {ticketTab === 'pipeline' && ( -
- {['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].map((state) => ( - ticket.topic || ticket.id)} - /> - ))} -
- )} - - {ticketTab === 'sla' && ( -
- `${row.supporter || '-'} · ${row.tickets || 0} Tickets · ${row.ttc || '-'} TTC · ${row.ttfr || '-'} TTFR`)} - /> - `${row.date || '-'} · ${row.tickets || 0} Tickets · ${row.ttc || '-'} TTC · ${row.ttfr || '-'} TTFR`)} - /> -
- )} - - {ticketTab === 'automations' && ( -
- `${rule.name || 'Automation'} · ${rule.active === false ? 'inaktiv' : 'aktiv'}`)} /> - - setAutomationDraft((state) => ({ ...state, name: value }))} /> - setAutomationDraft((state) => ({ ...state, conditionValue: value }))} /> -