feat: vollständiges Dashboard-Redesign mit HeroUI - monolithische App.tsx aufgelöst, 16 Seiten, Context-API, collapsible Sidebar, neues Dashboard-Layout
Some checks are pending
Deploy Discord Bot / deploy (push) Waiting to run
Some checks are pending
Deploy Discord Bot / deploy (push) Waiting to run
This commit is contained in:
1256
frontend/src/App.tsx
1256
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
39
frontend/src/components/layout/AppLayout.tsx
Normal file
39
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Spinner } from '@heroui/react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { loading, guilds } = useApp();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner color="primary" label="Dashboard wird geladen..." size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guilds.length) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-default-500">Keine Server verfügbar</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="hidden shrink-0 lg:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
<main className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1520px] px-6 py-6">
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/layout/Header.tsx
Normal file
88
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Button, Chip, Tooltip, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Avatar } from '@heroui/react';
|
||||
import { Moon, Sun, Bell, ChevronDown, LogOut, Settings } from 'lucide-react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
const navLabels: Record<string, string> = {
|
||||
overview: 'Übersicht',
|
||||
tickets: 'Ticketsystem',
|
||||
supportlogin: 'Support Login',
|
||||
automod: 'Automod',
|
||||
welcome: 'Willkommen',
|
||||
dynamicvoice: 'Dynamic Voice',
|
||||
birthday: 'Birthday',
|
||||
reactionroles: 'Reaction Roles',
|
||||
statuspage: 'Statuspage',
|
||||
serverstats: 'Server Stats',
|
||||
register: 'Registrierung',
|
||||
music: 'Musik',
|
||||
settings: 'Einstellungen',
|
||||
modules: 'Module',
|
||||
events: 'Events',
|
||||
admin: 'Admin',
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const { dark, toggle } = useTheme();
|
||||
const { guildName, statusMessage } = useHeaderData();
|
||||
const { user, handleLogout, setSection, section } = useApp();
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-default-400 min-w-0">
|
||||
<button className="hover:text-foreground transition-colors" onClick={() => setSection('overview')}>
|
||||
Dashboard
|
||||
</button>
|
||||
{section !== 'overview' && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium truncate">{navLabels[section] || section}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusMessage && (
|
||||
<Chip color="warning" size="sm" variant="flat" className="max-w-[200px]">
|
||||
<span className="truncate">{statusMessage}</span>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Tooltip content={dark ? 'Helles Design' : 'Dunkles Design'} placement="bottom">
|
||||
<Button isIconOnly radius="lg" size="sm" variant="light" onPress={toggle}>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button className="gap-2" radius="lg" size="sm" variant="light">
|
||||
<Avatar name={user?.username} size="sm" className="size-6" />
|
||||
<span className="hidden sm:inline text-sm">{user?.username}</span>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="User Menu">
|
||||
<DropdownItem key="settings" startContent={<Settings size={14} />} onPress={() => setSection('settings')}>
|
||||
Einstellungen
|
||||
</DropdownItem>
|
||||
<DropdownItem key="logout" startContent={<LogOut size={14} />} color="danger" onPress={handleLogout}>
|
||||
Abmelden
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useHeaderData() {
|
||||
const { guildInfo, guilds, currentGuildId } = useApp();
|
||||
const selectedGuild = guilds.find((g) => g.id === currentGuildId);
|
||||
return {
|
||||
guildName: guildInfo?.name || selectedGuild?.name || 'Dashboard',
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +1,194 @@
|
||||
import { Avatar, Button, Card, CardContent, Chip } from '@heroui/react';
|
||||
import { LogOut, Server } from 'lucide-react';
|
||||
import { navItems } from '../../utils/constants';
|
||||
import type { NavKey, Guild, User } from '../../types';
|
||||
import { useState } from 'react';
|
||||
import { Avatar, Button, Chip, ScrollShadow, Tooltip } from '@heroui/react';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, LogOut, Server, PanelLeftClose, PanelLeft,
|
||||
Activity, AudioLines, CalendarDays, ClipboardList, Home,
|
||||
LogIn, Music, Puzzle, RadioTower, Settings, Shield, Sparkles,
|
||||
Tag, Ticket, Wrench, Bot
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { guildIconUrl } from '../../utils/formatters';
|
||||
|
||||
type Props = {
|
||||
user: User | null;
|
||||
guilds: Guild[];
|
||||
currentGuildId: string;
|
||||
section: NavKey;
|
||||
onSectionChange: (key: NavKey) => void;
|
||||
onGuildChange: (id: string) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
const navGroups = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
items: [
|
||||
{ key: 'overview', label: 'Übersicht', icon: <Home size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Community',
|
||||
items: [
|
||||
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={18} /> },
|
||||
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
|
||||
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Support',
|
||||
items: [
|
||||
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={18} /> },
|
||||
{ key: 'supportlogin', label: 'Support Login', icon: <LogIn size={18} /> },
|
||||
{ key: 'register', label: 'Registrierung', icon: <ClipboardList size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Moderation',
|
||||
items: [
|
||||
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Funktionen',
|
||||
items: [
|
||||
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={18} /> },
|
||||
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
|
||||
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
|
||||
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
|
||||
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout } = useApp();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const selectedGuild = guilds.find((g) => g.id === currentGuildId);
|
||||
|
||||
export function Sidebar({ user, guilds, currentGuildId, section, onSectionChange, onGuildChange, onLogout }: Props) {
|
||||
return (
|
||||
<aside className="flex h-full flex-col gap-2 border-r border-default-100 bg-gradient-to-b from-default-50/40 to-background p-4">
|
||||
<div className="mb-4 flex items-center gap-3 px-2">
|
||||
<div className="flex size-11 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-400 to-primary-600 text-xl font-black text-white shadow-lg shadow-primary-500/25">
|
||||
P
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">Papo</div>
|
||||
<div className="text-tiny uppercase tracking-widest text-default-400">Dashboard</div>
|
||||
</div>
|
||||
<aside className={`flex h-full flex-col border-r border-default-100 bg-gradient-to-b from-default-50/40 to-background transition-all duration-200 ${collapsed ? 'w-16' : 'w-64'}`}>
|
||||
<div className={`flex items-center gap-3 px-4 pt-4 pb-3 ${collapsed ? 'justify-center' : ''}`}>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-primary-600 text-lg font-bold text-white shadow-lg shadow-primary-500/25">
|
||||
P
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-bold">Papo</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-default-400">Dashboard</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-primary-600 text-lg font-bold text-white shadow-lg shadow-primary-500/25">
|
||||
P
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="gap-2 p-3">
|
||||
<div className="flex items-center gap-2 text-tiny text-default-500">
|
||||
<Server size={14} />
|
||||
Server
|
||||
</div>
|
||||
<select
|
||||
className="w-full rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary-400"
|
||||
value={currentGuildId}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="h-px bg-default-200 my-2" />
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto">
|
||||
{navItems
|
||||
.filter((item) => item.key !== 'admin' || user?.isAdmin)
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.key}
|
||||
className={`justify-start px-3 ${section === item.key ? 'bg-primary-500/15 text-primary-400 font-semibold' : 'text-default-500 hover:text-foreground'}`}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
startContent={item.icon}
|
||||
onPress={() => onSectionChange(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
<div className={`px-3 pb-2 ${collapsed ? 'px-2' : ''}`}>
|
||||
<select
|
||||
className={`w-full rounded-xl border border-default-200 bg-default-50 text-sm text-foreground outline-none transition-colors focus:border-primary-400 ${collapsed ? 'px-1 py-2 text-center text-[10px]' : 'px-3 py-2'}`}
|
||||
value={currentGuildId}
|
||||
onChange={(e) => setCurrentGuildId(e.target.value)}
|
||||
title={selectedGuild?.name}
|
||||
>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>{collapsed ? g.name.slice(0, 2) : g.name}</option>
|
||||
))}
|
||||
</nav>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-default-200 my-2" />
|
||||
<div className="h-px bg-default-200 mx-3" />
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-row items-center gap-3 p-3">
|
||||
<Avatar name={user?.username} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{user?.username}</div>
|
||||
<div className="text-tiny text-default-400">Online</div>
|
||||
</div>
|
||||
<Button isIconOnly color="danger" radius="lg" size="sm" variant="light" onPress={onLogout}>
|
||||
<LogOut size={16} />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ScrollShadow className="flex-1 px-2 py-2" hideScrollBar>
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
{!collapsed && (
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-widest text-default-400">
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items
|
||||
.filter((item) => item.key !== 'admin' || user?.isAdmin)
|
||||
.map((item) => {
|
||||
const isActive = section === item.key;
|
||||
return (
|
||||
<Tooltip key={item.key} content={collapsed ? item.label : ''} placement="right" offset={8}>
|
||||
<Button
|
||||
className={`h-9 justify-start gap-3 px-2 ${isActive ? 'bg-primary-500/15 text-primary-400 font-semibold' : 'text-default-500 hover:text-foreground hover:bg-default-100/40'}`}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={item.icon}
|
||||
onPress={() => setSection(item.key)}
|
||||
>
|
||||
{!collapsed && item.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{user?.isAdmin && (
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-widest text-default-400">
|
||||
Admin
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={collapsed ? 'Admin' : ''} placement="right" offset={8}>
|
||||
<Button
|
||||
className={`h-9 justify-start gap-3 px-2 ${section === 'admin' ? 'bg-warning-500/15 text-warning-400 font-semibold' : 'text-default-500 hover:text-foreground hover:bg-default-100/40'}`}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<Wrench size={18} />}
|
||||
onPress={() => setSection('admin')}
|
||||
>
|
||||
{!collapsed && 'Admin'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</ScrollShadow>
|
||||
|
||||
<div className="h-px bg-default-200 mx-3" />
|
||||
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="w-full"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
||||
</Button>
|
||||
|
||||
<div className={`flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/20 p-2 ${collapsed ? 'justify-center' : ''}`}>
|
||||
<Avatar name={user?.username} size="sm" className="shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-xs font-semibold">{user?.username}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-success-400">
|
||||
<div className="size-1.5 rounded-full bg-success-400" />
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip content="Abmelden" placement="top">
|
||||
<Button isIconOnly color="danger" radius="lg" size="sm" variant="light" onPress={handleLogout}>
|
||||
<LogOut size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
20
frontend/src/components/shared/EmptyState.tsx
Normal file
20
frontend/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import { Inbox } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ message = 'Keine Daten vorhanden', icon }: Props) {
|
||||
return (
|
||||
<Card className="border border-default-100 bg-default-50/10">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-default-100/50 text-default-400">
|
||||
{icon || <Inbox size={24} />}
|
||||
</div>
|
||||
<p className="text-small text-default-400">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/shared/ErrorState.tsx
Normal file
25
frontend/src/components/shared/ErrorState.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export function ErrorState({ message = 'Ein Fehler ist aufgetreten', onRetry }: Props) {
|
||||
return (
|
||||
<Card className="border border-danger-100/30 bg-danger-50/10">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-danger-500/10 text-danger-400">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<p className="text-small text-default-500">{message}</p>
|
||||
{onRetry && (
|
||||
<button className="flex items-center gap-1 text-tiny text-primary-400 hover:text-primary-300 transition-colors" onClick={onRetry}>
|
||||
<RefreshCw size={12} /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
lines?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function LoadingSkeleton({ lines = 3, children }: Props) {
|
||||
if (children) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Card key={i} className="animate-pulse border border-default-100 bg-default-50/30">
|
||||
<CardContent className="p-4">{children}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({ lines = 3 }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
|
||||
@@ -4,18 +4,27 @@ import type { ReactNode } from 'react';
|
||||
type Props = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'default';
|
||||
};
|
||||
|
||||
export function StatCard({ icon, label, value }: Props) {
|
||||
export function StatCard({ icon, label, value, trend, color = 'primary' }: Props) {
|
||||
return (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card className="border border-default-100 bg-gradient-to-br from-default-50/20 to-background hover:border-default-200 transition-all">
|
||||
<CardContent className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
|
||||
<span className="text-primary-400">{icon}</span>
|
||||
{label}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`flex size-9 items-center justify-center rounded-xl bg-${color}-500/10 text-${color}-400`}>
|
||||
{icon}
|
||||
</div>
|
||||
{trend && (
|
||||
<span className={`text-tiny font-medium ${trend.startsWith('+') ? 'text-success-400' : trend.startsWith('-') ? 'text-danger-400' : 'text-default-400'}`}>
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-tight">{value}</div>
|
||||
<div className="text-tiny uppercase tracking-widest text-default-500">{label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
511
frontend/src/context/AppContext.tsx
Normal file
511
frontend/src/context/AppContext.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { apiFetch } from '../utils/api';
|
||||
import type {
|
||||
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||
RegisterApplication, MusicSession
|
||||
} from '../types';
|
||||
|
||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||
|
||||
type AppState = {
|
||||
user: User | null;
|
||||
guilds: Guild[];
|
||||
currentGuildId: string;
|
||||
section: NavKey;
|
||||
guildInfo: any;
|
||||
overview: any;
|
||||
activity: any;
|
||||
logs: LogEntry[];
|
||||
tickets: TicketRecord[];
|
||||
pipeline: Record<string, TicketRecord[]>;
|
||||
sla: any;
|
||||
automations: any[];
|
||||
kbArticles: any[];
|
||||
settings: SettingsState;
|
||||
modules: ModuleItem[];
|
||||
birthday: any;
|
||||
reactionRoles: ReactionRoleSet[];
|
||||
statuspage: any;
|
||||
serverStats: any;
|
||||
events: EventItem[];
|
||||
admin: any;
|
||||
statusMessage: string;
|
||||
loading: boolean;
|
||||
supportLogin: { config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null;
|
||||
registerForms: RegisterForm[];
|
||||
registerApps: RegisterApplication[];
|
||||
musicStatus: { activeGuilds: number; sessions: MusicSession[] };
|
||||
};
|
||||
|
||||
type AppContextType = AppState & {
|
||||
setCurrentGuildId: (id: string) => void;
|
||||
setSection: (key: NavKey) => void;
|
||||
setSettings: (s: SettingsState | ((prev: SettingsState) => SettingsState)) => void;
|
||||
setBirthday: (s: any | ((prev: any) => any)) => void;
|
||||
setSupportLogin: (s: any | ((prev: any) => any)) => void;
|
||||
setStatusDraft: (s: any | ((prev: any) => any)) => void;
|
||||
setStatsDraft: (s: any | ((prev: any) => any)) => void;
|
||||
setStatusMessage: (msg: string) => void;
|
||||
loadGuildData: (guildId: string) => Promise<void>;
|
||||
saveSettingsPayload: (payload: Record<string, any>, okMessage: string) => Promise<void>;
|
||||
saveBirthday: () => Promise<void>;
|
||||
saveStatuspage: () => Promise<void>;
|
||||
saveServerStats: () => Promise<void>;
|
||||
toggleModule: (key: string, enabled: boolean) => Promise<void>;
|
||||
handleLogout: () => void;
|
||||
loadTicketData: (guildId: string) => Promise<void>;
|
||||
loadTicketMessages: (ticketId: string) => Promise<void>;
|
||||
updateTicketStatus: (ticketId: string, status: string) => Promise<void>;
|
||||
closeTicket: (ticketId: string) => Promise<void>;
|
||||
saveAutomation: () => Promise<void>;
|
||||
saveKbArticle: () => Promise<void>;
|
||||
updateKbArticle: (id: string) => Promise<void>;
|
||||
deleteKbArticle: (id: string) => Promise<void>;
|
||||
updateAutomation: (id: string) => Promise<void>;
|
||||
deleteAutomation: (id: string) => Promise<void>;
|
||||
saveSupportLogin: () => Promise<void>;
|
||||
saveForm: () => Promise<void>;
|
||||
deleteForm: (id: string) => Promise<void>;
|
||||
sendFormPanel: (formId: string) => Promise<void>;
|
||||
addStatusService: () => Promise<void>;
|
||||
deleteStatusService: (id: string) => Promise<void>;
|
||||
addStatsItem: () => Promise<void>;
|
||||
deleteStatsItem: (index: number) => Promise<void>;
|
||||
saveEvent: () => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
saveReactionRole: () => Promise<void>;
|
||||
ticketTab: string;
|
||||
setTicketTab: (tab: string) => void;
|
||||
automationDraft: any;
|
||||
setAutomationDraft: (s: any | ((prev: any) => any)) => void;
|
||||
kbDraft: any;
|
||||
setKbDraft: (s: any | ((prev: any) => any)) => void;
|
||||
eventDraft: any;
|
||||
setEventDraft: (s: any | ((prev: any) => any)) => void;
|
||||
statusDraft: any;
|
||||
statsDraft: any;
|
||||
reactionDraft: any;
|
||||
setReactionDraft: (s: any | ((prev: any) => any)) => void;
|
||||
formDraft: any;
|
||||
setFormDraft: (s: any | ((prev: any) => any)) => void;
|
||||
editingFormId: string | null;
|
||||
setEditingFormId: (id: string | null) => void;
|
||||
registerTab: string;
|
||||
setRegisterTab: (tab: string) => void;
|
||||
statusServiceDraft: any;
|
||||
setStatusServiceDraft: (s: any | ((prev: any) => any)) => void;
|
||||
statsItemDraft: any;
|
||||
setStatsItemDraft: (s: any | ((prev: any) => any)) => void;
|
||||
ticketDetail: TicketRecord | null;
|
||||
setTicketDetail: (t: TicketRecord | null) => void;
|
||||
ticketMessages: any[];
|
||||
kbEditDraft: any;
|
||||
setKbEditDraft: (s: any | ((prev: any) => any)) => void;
|
||||
automationEditDraft: any;
|
||||
setAutomationEditDraft: (s: any | ((prev: any) => any)) => void;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
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, setSectionState] = 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: '' });
|
||||
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
|
||||
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
|
||||
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
|
||||
const [formDraft, setFormDraft] = useState({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
|
||||
const [editingFormId, setEditingFormId] = useState<string | null>(null);
|
||||
const [registerTab, setRegisterTab] = useState('forms');
|
||||
const [musicStatus, setMusicStatus] = useState<{ activeGuilds: number; sessions: MusicSession[] }>({ activeGuilds: 0, sessions: [] });
|
||||
const [kbEditDraft, setKbEditDraft] = useState<{ id: string; title: string; keywords: string; content: string } | null>(null);
|
||||
const [automationEditDraft, setAutomationEditDraft] = useState<{ id: string; name: string; conditionValue: string; actionValue: string } | null>(null);
|
||||
const [statusServiceDraft, setStatusServiceDraft] = useState<{ id?: string; name: string; url: string; status: string }>({ name: '', url: '', status: 'unknown' });
|
||||
const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' });
|
||||
const [ticketDetail, setTicketDetail] = useState<TicketRecord | null>(null);
|
||||
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
|
||||
|
||||
const setSection = useCallback((key: NavKey) => {
|
||||
setSectionState(key);
|
||||
window.location.hash = key;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '') as NavKey;
|
||||
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin'];
|
||||
if (validKeys.includes(hash)) setSectionState(hash);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadGuildData(currentGuildId);
|
||||
}, [currentGuildId]);
|
||||
|
||||
useEffect(() => { bootstrap(); }, []);
|
||||
|
||||
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 loadTicketData(guildId: string) {
|
||||
try {
|
||||
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 || []);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadGuildData(guildId: string) {
|
||||
setStatusMessage('Lade Daten...');
|
||||
try {
|
||||
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
||||
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
|
||||
registerFormsRes, registerAppsRes] = 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)}`),
|
||||
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
||||
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
|
||||
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`)
|
||||
]);
|
||||
setGuildInfo(guildInfoRes.guild || null);
|
||||
setOverview(overviewRes);
|
||||
setMusicStatus(overviewRes.music || { activeGuilds: 0, sessions: [] });
|
||||
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 || []);
|
||||
setSupportLogin(supportLoginRes);
|
||||
setRegisterForms(registerFormsRes.forms || []);
|
||||
setRegisterApps(registerAppsRes.applications || []);
|
||||
setReactionDraft({ title: '', channelId: '', entries: '' });
|
||||
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
||||
setStatusMessage('');
|
||||
} catch { setStatusMessage('Daten konnten nicht geladen werden'); }
|
||||
}
|
||||
|
||||
async function loadAdminData() {
|
||||
if (!user?.isAdmin) return;
|
||||
try {
|
||||
const [overviewRes, , logsRes] = await Promise.all([
|
||||
apiFetch<any>('/admin/overview'),
|
||||
apiFetch<any>('/admin/activity'),
|
||||
apiFetch<any>('/admin/logs')
|
||||
]);
|
||||
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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((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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function updateKbArticle(id: string) {
|
||||
if (!kbEditDraft) return;
|
||||
await apiFetch(`/kb/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ guildId: currentGuildId, title: kbEditDraft.title, keywords: kbEditDraft.keywords, content: kbEditDraft.content })
|
||||
});
|
||||
setKbEditDraft(null);
|
||||
setStatusMessage('KB-Artikel aktualisiert');
|
||||
await loadTicketData(currentGuildId);
|
||||
}
|
||||
|
||||
async function deleteKbArticle(id: string) {
|
||||
await apiFetch(`/kb/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||
setStatusMessage('KB-Artikel gelöscht');
|
||||
await loadTicketData(currentGuildId);
|
||||
}
|
||||
|
||||
async function updateAutomation(id: string) {
|
||||
if (!automationEditDraft) return;
|
||||
await apiFetch(`/automations/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ guildId: currentGuildId, name: automationEditDraft.name, condition: { category: automationEditDraft.conditionValue }, action: { type: 'reminder', message: automationEditDraft.actionValue }, active: true })
|
||||
});
|
||||
setAutomationEditDraft(null);
|
||||
setStatusMessage('Automation aktualisiert');
|
||||
await loadTicketData(currentGuildId);
|
||||
}
|
||||
|
||||
async function deleteAutomation(id: string) {
|
||||
await apiFetch(`/automations/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||
setStatusMessage('Automation gelöscht');
|
||||
await loadTicketData(currentGuildId);
|
||||
}
|
||||
|
||||
async function saveSupportLogin() {
|
||||
if (!supportLogin) return;
|
||||
await apiFetch('/tickets/support-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guildId: currentGuildId, ...supportLogin.config })
|
||||
});
|
||||
setStatusMessage('Support Login gespeichert');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const fields = formDraft.fields.split('\n').filter(Boolean).map((line) => {
|
||||
const parts = line.split('|').map((s) => s.trim());
|
||||
return { label: parts[0] || 'Feld', type: (parts[1] || 'text') as any, required: parts[2] === 'required', options: parts[3] ? parts[3].split(',').map((s) => s.trim()) : undefined };
|
||||
});
|
||||
const body: any = { guildId: currentGuildId, name: formDraft.name, description: formDraft.description, reviewChannelId: formDraft.reviewChannelId || undefined, notifyRoleIds: formDraft.notifyRoleIds.split(',').map((s) => s.trim()).filter(Boolean), fields, isActive: true };
|
||||
if (editingFormId) {
|
||||
await apiFetch(`/register/forms/${editingFormId}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
setStatusMessage('Formular aktualisiert');
|
||||
} else {
|
||||
await apiFetch('/register/forms', { method: 'POST', body: JSON.stringify(body) });
|
||||
setStatusMessage('Formular erstellt');
|
||||
}
|
||||
setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
|
||||
setEditingFormId(null);
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function deleteForm(id: string) {
|
||||
await apiFetch(`/register/forms/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||
setStatusMessage('Formular gelöscht');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function sendFormPanel(formId: string) {
|
||||
if (!supportLogin?.config?.panelChannelId) { setStatusMessage('Bitte zuerst Support Login konfigurieren'); return; }
|
||||
await apiFetch(`/register/forms/${formId}/panel`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: supportLogin.config.panelChannelId }) });
|
||||
setStatusMessage('Panel gesendet');
|
||||
}
|
||||
|
||||
async function addStatusService() {
|
||||
if (!statusServiceDraft.name) return;
|
||||
await apiFetch('/statuspage/service', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guildId: currentGuildId, name: statusServiceDraft.name, url: statusServiceDraft.url, status: statusServiceDraft.status })
|
||||
});
|
||||
setStatusServiceDraft({ name: '', url: '', status: 'unknown' });
|
||||
setStatusMessage('Service hinzugefügt');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function deleteStatusService(id: string) {
|
||||
await apiFetch(`/statuspage/service/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||
setStatusMessage('Service entfernt');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function addStatsItem() {
|
||||
if (!statsItemDraft.label) return;
|
||||
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
|
||||
const items = [...(draft.items || []), { key: statsItemDraft.label.toLowerCase().replace(/\s+/g, '_'), label: statsItemDraft.label, type: statsItemDraft.type }];
|
||||
const updated = { ...draft, items };
|
||||
setStatsDraft(updated);
|
||||
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
|
||||
setStatsItemDraft({ label: '', type: 'members' });
|
||||
setStatusMessage('Stat-Item hinzugefügt');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function deleteStatsItem(index: number) {
|
||||
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
|
||||
const items = (draft.items || []).filter((_: any, i: number) => i !== index);
|
||||
const updated = { ...draft, items };
|
||||
setStatsDraft(updated);
|
||||
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
|
||||
setStatusMessage('Stat-Item entfernt');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function loadTicketMessages(ticketId: string) {
|
||||
const res = await apiFetch<any>(`/tickets/${ticketId}/messages`);
|
||||
setTicketMessages(res.messages || []);
|
||||
}
|
||||
|
||||
async function updateTicketStatus(ticketId: string, status: string) {
|
||||
await apiFetch(`/tickets/${ticketId}/status`, { method: 'POST', body: JSON.stringify({ status }) });
|
||||
setStatusMessage('Status aktualisiert');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function closeTicket(ticketId: string) {
|
||||
await apiFetch(`/tickets/${ticketId}/close`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||
setStatusMessage('Ticket geschlossen');
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{
|
||||
user, guilds, currentGuildId, section, guildInfo, overview, activity,
|
||||
logs, tickets, pipeline, sla, automations, kbArticles, settings, modules,
|
||||
birthday, reactionRoles, statuspage, serverStats, events, admin, statusMessage,
|
||||
loading, supportLogin, registerForms, registerApps, musicStatus, ticketTab,
|
||||
automationDraft, kbDraft, eventDraft, statusDraft, statsDraft, reactionDraft,
|
||||
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||
toggleModule, handleLogout, loadTicketData, loadTicketMessages,
|
||||
updateTicketStatus, closeTicket, saveAutomation, saveKbArticle,
|
||||
updateKbArticle, deleteKbArticle, updateAutomation, deleteAutomation,
|
||||
saveSupportLogin, saveForm, deleteForm, sendFormPanel, addStatusService,
|
||||
deleteStatusService, addStatsItem, deleteStatsItem, saveEvent, deleteEvent,
|
||||
saveReactionRole, setTicketTab, setAutomationDraft, setKbDraft, setEventDraft,
|
||||
setReactionDraft, setFormDraft, setEditingFormId, setRegisterTab,
|
||||
setStatusServiceDraft, setStatsItemDraft, setTicketDetail, setKbEditDraft,
|
||||
setAutomationEditDraft,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within AppProvider');
|
||||
return ctx;
|
||||
}
|
||||
25
frontend/src/hooks/useGuildResources.ts
Normal file
25
frontend/src/hooks/useGuildResources.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
type Channel = { id: string; name: string; type: string; parentId?: string };
|
||||
type Role = { id: string; name: string; color: string };
|
||||
type Category = { id: string; name: string };
|
||||
|
||||
export function useGuildResources(guildId?: string) {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guildId) return;
|
||||
apiFetch<{ channels: Channel[]; roles: Role[]; categories: Category[] }>(
|
||||
`/guild/resources?guildId=${encodeURIComponent(guildId)}`
|
||||
).then((res) => {
|
||||
setChannels(res.channels || []);
|
||||
setRoles(res.roles || []);
|
||||
setCategories(res.categories || []);
|
||||
}).catch(() => {});
|
||||
}, [guildId]);
|
||||
|
||||
return { channels, roles, categories };
|
||||
}
|
||||
14
frontend/src/hooks/useTheme.ts
Normal file
14
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('papo-theme') !== 'light');
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
localStorage.setItem('papo-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
|
||||
const toggle = useCallback(() => setDark((d) => !d), []);
|
||||
|
||||
return { dark, toggle };
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AppProvider } from './context/AppContext';
|
||||
import './app.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
71
frontend/src/pages/Admin.tsx
Normal file
71
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
|
||||
import { Wrench, Activity, Clock, Server, Terminal } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
export function Admin() {
|
||||
const { user, admin } = useApp();
|
||||
|
||||
if (!user?.isAdmin) return null;
|
||||
|
||||
return (
|
||||
<SectionCard title="Admin" subtitle="Bot-weite <20>bersichten">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Bot Overview</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={14} className="text-primary-400" />
|
||||
<span className="text-default-500">Guilds</span>
|
||||
</div>
|
||||
<span className="font-semibold">{admin.overview?.guilds ?? '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={14} className="text-success-400" />
|
||||
<span className="text-default-500">Aktive Guilds (24h)</span>
|
||||
</div>
|
||||
<span className="font-semibold">{admin.overview?.activeGuilds ?? '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-warning-400" />
|
||||
<span className="text-default-500">Uptime</span>
|
||||
</div>
|
||||
<span className="font-semibold">{admin.overview?.uptime ?? '-'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
|
||||
<Chip size="sm" variant="flat" color="warning">
|
||||
{(admin.logs || []).length} Eintr<EFBFBD>ge
|
||||
</Chip>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{(admin.logs || []).length ? (admin.logs || []).slice(0, 20).map((log, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<Terminal size={14} className="mt-0.5 text-default-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-default-500">{log.message || '-'}</p>
|
||||
<p className="text-tiny text-default-400 mt-0.5">{formatDate(log.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<Terminal size={20} />
|
||||
Keine Logs
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/Automod.tsx
Normal file
81
frontend/src/pages/Automod.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { Shield, Filter, Link, Ban, AlertTriangle, Save } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function Automod() {
|
||||
const { settings, setSettings, saveSettingsPayload } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Filter konfigurieren</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={settings.automodEnabled !== false} onValueChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} /> Automod aktiv
|
||||
</div>
|
||||
</Switch>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Switch isSelected={settings.automodConfig?.badWordFilter ?? false} onValueChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), badWordFilter: v } }))}>
|
||||
<div className="flex items-center gap-2"><Ban size={14} /> Bad-Word-Filter</div>
|
||||
</Switch>
|
||||
<Switch isSelected={settings.automodConfig?.linkFilter ?? false} onValueChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkFilter: v } }))}>
|
||||
<div className="flex items-center gap-2"><Link size={14} /> Link-Filter</div>
|
||||
</Switch>
|
||||
<Switch isSelected={settings.automodConfig?.spamFilter ?? false} onValueChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), spamFilter: v } }))}>
|
||||
<div className="flex items-center gap-2"><AlertTriangle size={14} /> Spam-Filter</div>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Log Channel ID"
|
||||
placeholder="Channel ID f<>r Logs"
|
||||
value={settings.automodConfig?.logChannelId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: v } }))}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Whitelist Links (Komma-getrennt)"
|
||||
value={(settings.automodConfig?.linkWhitelist || []).join(', ')}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkWhitelist: v.split(',').map((x) => x.trim()).filter(Boolean) } }))}
|
||||
placeholder="trusted-domain.com, another-safe.site"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: settings.automodConfig || {} }, 'Automod gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Info</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<p className="text-default-500">Die Automod-Einstellungen werden nach dem Speichern sofort aktiv.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<p className="text-default-500">Bad-Word-Filter entfernt Nachrichten mit unerw<EFBFBD>nschten Begriffen.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<p className="text-default-500">Link-Filter blockiert bekannte sch<EFBFBD>dliche Domains und nicht-whitelistete Links.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<p className="text-default-500">Spam-Filter erkennt und unterdr<EFBFBD>ckt Mehrfachnachrichten in kurzer Zeit.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
77
frontend/src/pages/Birthday.tsx
Normal file
77
frontend/src/pages/Birthday.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { CalendarDays, Save, Cake, Clock } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function Birthday() {
|
||||
const { birthday, setBirthday, saveBirthday } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Eintr<74>ge">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={birthday.config?.enabled !== false} onValueChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}>
|
||||
<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div>
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Channel ID"
|
||||
placeholder="Channel f<>r Geburtstagsnachrichten"
|
||||
value={birthday.config?.channelId || ''}
|
||||
onValueChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: v } }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Sendezeit (Stunde)"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={String(birthday.config?.sendHour ?? 9)}
|
||||
onValueChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, sendHour: Number(v || 0) } }))}
|
||||
startContent={<Clock size={16} className="text-default-400" />}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Template"
|
||||
placeholder="Alles Gute zum Geburtstag, {user}!"
|
||||
value={birthday.config?.messageTemplate || ''}
|
||||
onValueChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, messageTemplate: v } }))}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveBirthday}>Speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Gespeicherte Geburtstage ({(birthday.birthdays || []).length})</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{(birthday.birthdays || []).length ? (birthday.birthdays || []).map((entry, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cake size={14} className="text-primary-400" />
|
||||
<span className="font-medium">{entry.userId}</span>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color="primary">
|
||||
{String(entry.birthDate || '').replace(/^--/, '')}
|
||||
</Chip>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<CalendarDays size={20} />
|
||||
Keine Eintr<EFBFBD>ge
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/Dashboard.tsx
Normal file
208
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Card, CardContent, CardHeader, Avatar, Chip, Button, Separator, ScrollShadow } from '@heroui/react';
|
||||
import {
|
||||
Bot, UserRound, CalendarDays, Users, Cable, Ticket, Shield, MessageSquare,
|
||||
Command, ChevronRight, Activity, Clock, ArrowUpRight, RefreshCw, Send,
|
||||
Settings, Trash2, Sparkles, Home, Server, Hash, Gauge, Zap, Bell, Tag
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { formatDate, guildIconUrl } from '../utils/formatters';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
|
||||
export function Dashboard() {
|
||||
const { guildInfo, guilds, currentGuildId, overview, activity, logs, setSection, section } = useApp();
|
||||
const selectedGuild = guilds.find((g) => g.id === currentGuildId);
|
||||
const moduleFlags = guildInfo?.modules || {};
|
||||
|
||||
const quickActions = [
|
||||
{ key: 'tickets', label: 'Ticket Panel senden', icon: <Send size={16} />, color: 'primary' as const },
|
||||
{ key: 'serverstats', label: 'Sync starten', icon: <RefreshCw size={16} />, color: 'success' as const },
|
||||
{ key: 'modules', label: 'Module aktualisieren', icon: <Zap size={16} />, color: 'warning' as const },
|
||||
{ key: 'settings', label: 'Einstellungen öffnen', icon: <Settings size={16} />, color: 'default' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-default-100 bg-gradient-to-r from-primary-500/5 via-default-50/40 to-background overflow-hidden">
|
||||
<CardContent className="flex flex-col gap-6 p-6 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-5">
|
||||
<Avatar className="size-20 shrink-0" radius="lg" src={guildIconUrl(selectedGuild)} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="truncate text-3xl font-black tracking-tight">{guildInfo?.name || selectedGuild?.name}</h1>
|
||||
<Chip color="success" radius="sm" size="sm" startContent={<Bot size={12} />} variant="dot">
|
||||
Online
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="mt-1 text-small text-default-400">ID: {guildInfo?.id || selectedGuild?.id}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(moduleFlags).filter(([, v]) => v).map(([key]) => (
|
||||
<Chip key={key} color="primary" size="sm" variant="flat">
|
||||
{key.replace('Enabled', '').replace(/([A-Z])/g, ' $1').trim()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Chip color="primary" radius="sm" size="sm" variant="flat" startContent={<Gauge size={12} />}>
|
||||
Ping: {guildInfo?.ping || '-'}ms
|
||||
</Chip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<StatCard icon={<Users size={18} />} label="Mitglieder" value={guildInfo?.memberCount ?? 0} />
|
||||
<StatCard icon={<Activity size={18} />} label="Online" value={guildInfo?.onlineCount ?? '-'} />
|
||||
<StatCard icon={<Hash size={18} />} label="Channels" value={`${guildInfo?.textCount || 0}`} />
|
||||
<StatCard icon={<Shield size={18} />} label="Rollen" value={guildInfo?.roleCount ?? '-'} />
|
||||
<StatCard icon={<ArrowUpRight size={18} />} label="Boost Level" value={guildInfo?.boostLevel ?? '-'} />
|
||||
<StatCard icon={<Ticket size={18} />} label="Offene Tickets" value={overview?.tickets?.open ?? 0} color="warning" />
|
||||
<StatCard icon={<Command size={18} />} label="Commands (24h)" value={activity?.commands24h ?? 0} />
|
||||
<StatCard icon={<MessageSquare size={18} />} label="Nachrichten (24h)" value={activity?.messages24h ?? 0} />
|
||||
<StatCard icon={<Bot size={18} />} label="Automod (24h)" value={activity?.automod24h ?? 0} color="danger" />
|
||||
<StatCard icon={<Clock size={18} />} label="Uptime" value={guildInfo?.uptime || '-'} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<Card className="border border-default-100 bg-gradient-to-b from-default-50/40 to-background">
|
||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Activity Bereich</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Live Statistiken</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/20 p-4">
|
||||
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
|
||||
<MessageSquare size={14} className="text-primary-400" /> Nachrichten
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black">{activity?.messages24h ?? 0}</div>
|
||||
<div className="text-tiny text-default-400">in den letzten 24h</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/20 p-4">
|
||||
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
|
||||
<Command size={14} className="text-success-400" /> Commands
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black">{activity?.commands24h ?? 0}</div>
|
||||
<div className="text-tiny text-default-400">in den letzten 24h</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/20 p-4">
|
||||
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
|
||||
<Shield size={14} className="text-warning-400" /> Automod
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black">{activity?.automod24h ?? 0}</div>
|
||||
<div className="text-tiny text-default-400">Aktionen (24h)</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-default-100 bg-default-50/20 p-4">
|
||||
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
|
||||
<Users size={14} className="text-secondary-400" /> Neue User
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black">{activity?.newUsers24h ?? 0}</div>
|
||||
<div className="text-tiny text-default-400">Beitritte (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-gradient-to-b from-default-50/40 to-background">
|
||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Guild Logs</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Letzte Ereignisse</p>
|
||||
</div>
|
||||
<Button size="sm" variant="light" endContent={<ChevronRight size={14} />} onPress={() => setSection('settings')}>
|
||||
Alle
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<ScrollShadow className="max-h-[320px] space-y-2 pr-1" hideScrollBar>
|
||||
{logs.length ? logs.slice(0, 15).map((log, i) => (
|
||||
<div key={`${log.timestamp}-${i}`} className="flex items-start gap-3 rounded-xl border border-default-100 bg-default-50/20 p-3">
|
||||
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
|
||||
log.level === 'error' ? 'bg-danger-500/10 text-danger-400' :
|
||||
log.level === 'warn' ? 'bg-warning-500/10 text-warning-400' :
|
||||
'bg-primary-500/10 text-primary-400'
|
||||
}`}>
|
||||
{log.level === 'error' ? <Shield size={12} /> :
|
||||
log.level === 'warn' ? <Bell size={12} /> :
|
||||
<Activity size={12} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color={log.level === 'error' ? 'danger' : log.level === 'warn' ? 'warning' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="h-5"
|
||||
>
|
||||
{(log.level || 'info').toUpperCase()}
|
||||
</Chip>
|
||||
<span className="text-tiny text-default-400">{formatDate(log.timestamp)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-small text-foreground/80">
|
||||
{log.category ? <span className="text-default-500">[{log.category}] </span> : ''}{log.message || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center text-small text-default-400">
|
||||
<Activity size={20} />
|
||||
Keine Logs
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-gradient-to-b from-default-50/40 to-background">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h2 className="text-lg font-bold">Quick Actions</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Schnellzugriff</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
className="justify-start"
|
||||
color={action.color}
|
||||
startContent={action.icon}
|
||||
variant="flat"
|
||||
onPress={() => setSection(action.key as any)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{(['tickets', 'supportlogin', 'automod', 'welcome', 'birthday', 'reactionroles'] as const).map((key) => {
|
||||
const item = navItemMap[key];
|
||||
return (
|
||||
<Card key={key} isPressable className="border border-default-100 bg-default-50/20 transition-all hover:border-primary-300 hover:shadow-md" onPress={() => setSection(key)}>
|
||||
<CardContent className="flex flex-col items-start gap-2 p-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="font-semibold text-sm">{item.label}</div>
|
||||
<div className="text-tiny text-default-400">Modul verwalten</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navItemMap: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
tickets: { label: 'Ticketsystem', icon: <Ticket size={18} /> },
|
||||
supportlogin: { label: 'Support Login', icon: <Send size={18} /> },
|
||||
automod: { label: 'Automod', icon: <Shield size={18} /> },
|
||||
welcome: { label: 'Willkommen', icon: <Sparkles size={18} /> },
|
||||
birthday: { label: 'Birthday', icon: <CalendarDays size={18} /> },
|
||||
reactionroles: { label: 'Reaction Roles', icon: <Tag size={18} /> },
|
||||
};
|
||||
70
frontend/src/pages/DynamicVoice.tsx
Normal file
70
frontend/src/pages/DynamicVoice.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { AudioLines, Save, Mic, Users } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function DynamicVoice() {
|
||||
const { settings, setSettings, saveSettingsPayload } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Dynamic Voice" subtitle="Voice-Lobby, Template und Limits">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={settings.dynamicVoiceEnabled !== false} onValueChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}>
|
||||
<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Lobby Channel ID"
|
||||
placeholder="Channel ID der Lobby"
|
||||
value={settings.dynamicVoiceConfig?.lobbyChannelId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: v } }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Kategorie ID"
|
||||
placeholder="Kategorie f<>r neue Channels"
|
||||
value={settings.dynamicVoiceConfig?.categoryId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: v } }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Template"
|
||||
placeholder="Channel-Name Template"
|
||||
value={settings.dynamicVoiceConfig?.template || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), template: v } }))}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Info</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<Mic size={16} className="text-primary-400" />
|
||||
<span className="text-default-500">Benutzer erstellen eigene Voice-Channels durch Beitreten der Lobby</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<Users size={16} className="text-success-400" />
|
||||
<span className="text-default-500">Channel-Owner k<EFBFBD>nnen Limits und Berechtigungen verwalten</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
86
frontend/src/pages/Events.tsx
Normal file
86
frontend/src/pages/Events.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator } from '@heroui/react';
|
||||
import { CalendarDays, Trash2, Plus, Clock } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
export function Events() {
|
||||
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow">
|
||||
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Bestehende Events ({(events || []).length})</h3>
|
||||
<div className="space-y-3">
|
||||
{(events || []).length ? (events || []).map((event) => (
|
||||
<Card key={event.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarDays size={16} className="text-primary-400 shrink-0" />
|
||||
<span className="font-semibold text-small truncate">{event.title}</span>
|
||||
</div>
|
||||
<Button color="danger" size="sm" variant="flat" startContent={<Trash2 size={14} />} onPress={() => deleteEvent(event.id)}>
|
||||
L<EFBFBD>schen
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-small text-default-400">{event.description || 'Keine Beschreibung'}</p>
|
||||
<div className="flex items-center gap-2 text-tiny text-default-500">
|
||||
<Clock size={12} />
|
||||
{formatDate(event.startsAt)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<CalendarDays size={24} />
|
||||
Keine Events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neues Event</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input
|
||||
label="Titel"
|
||||
placeholder="Event Name"
|
||||
value={eventDraft.title}
|
||||
onValueChange={(v) => setEventDraft((s) => ({ ...s, title: v }))}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Beschreibung"
|
||||
placeholder="Event Beschreibung"
|
||||
value={eventDraft.description}
|
||||
onValueChange={(v) => setEventDraft((s) => ({ ...s, description: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Channel ID"
|
||||
placeholder="Channel f<>r Erinnerungen"
|
||||
value={eventDraft.channelId}
|
||||
onValueChange={(v) => setEventDraft((s) => ({ ...s, channelId: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Start (ISO)"
|
||||
type="datetime-local"
|
||||
placeholder="2024-12-24T18:00"
|
||||
value={eventDraft.startsAt}
|
||||
onValueChange={(v) => setEventDraft((s) => ({ ...s, startsAt: v }))}
|
||||
/>
|
||||
|
||||
<Button color="primary" startContent={<Plus size={16} />} onPress={saveEvent}>
|
||||
Event speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
38
frontend/src/pages/GuildSelect.tsx
Normal file
38
frontend/src/pages/GuildSelect.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Card, CardContent, Avatar, Button } from '@heroui/react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { guildIconUrl } from '../utils/formatters';
|
||||
|
||||
export function GuildSelect() {
|
||||
const { guilds, setCurrentGuildId } = useApp();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl flex-col items-center justify-center px-6 py-12">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-400 to-primary-600 text-2xl font-black text-white shadow-lg shadow-primary-500/25">
|
||||
P
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Wähle einen Server</h1>
|
||||
<p className="mt-2 text-default-500">Wähle einen Discord-Server aus, um das Dashboard zu öffnen.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{guilds.map((guild) => (
|
||||
<Card
|
||||
key={guild.id}
|
||||
isPressable
|
||||
className="border border-default-100 bg-default-50/20 transition-all hover:border-primary-300 hover:shadow-lg hover:shadow-primary-500/5"
|
||||
onPress={() => setCurrentGuildId(guild.id)}
|
||||
>
|
||||
<CardContent className="flex flex-row items-center gap-4 p-5">
|
||||
<Avatar src={guildIconUrl(guild)} name={guild.name} radius="lg" size="lg" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold">{guild.name}</div>
|
||||
<div className="mt-0.5 text-small text-default-400">ID: {guild.id}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
frontend/src/pages/Modules.tsx
Normal file
72
frontend/src/pages/Modules.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, Switch } from '@heroui/react';
|
||||
import { Puzzle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function ModulesPage() {
|
||||
const { modules, toggleModule } = useApp();
|
||||
|
||||
const activeModules = modules.filter((m) => m.enabled);
|
||||
const inactiveModules = modules.filter((m) => !m.enabled);
|
||||
|
||||
return (
|
||||
<SectionCard title="Module" subtitle="Module direkt umschalten">
|
||||
<div className="space-y-5">
|
||||
{activeModules.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<CheckCircle size={16} className="text-success-400" />
|
||||
Aktive Module ({activeModules.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{activeModules.map((module) => (
|
||||
<Card key={module.key} className="border border-success-100/30 bg-success-50/5">
|
||||
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{module.name}</div>
|
||||
{module.description && (
|
||||
<div className="text-small text-default-400 truncate">{module.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch isSelected={module.enabled} onValueChange={(v) => toggleModule(module.key, v)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inactiveModules.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<XCircle size={16} className="text-default-400" />
|
||||
Deaktivierte Module ({inactiveModules.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{inactiveModules.map((module) => (
|
||||
<Card key={module.key} className="border border-default-100 bg-default-50/20 opacity-70">
|
||||
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{module.name}</div>
|
||||
{module.description && (
|
||||
<div className="text-small text-default-400 truncate">{module.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch isSelected={module.enabled} onValueChange={(v) => toggleModule(module.key, v)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modules.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<Puzzle size={24} />
|
||||
Keine Module verf<EFBFBD>gbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
62
frontend/src/pages/Music.tsx
Normal file
62
frontend/src/pages/Music.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
|
||||
import { Music, Play, List, Repeat, ExternalLink } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
|
||||
export function MusicPage() {
|
||||
const { musicStatus } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Musik-Status" subtitle="Aktuelle Wiedergabe und Queues pro Guild.">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<StatCard icon={<Music size={18} />} label="Aktive Guilds" value={musicStatus.activeGuilds} color="primary" />
|
||||
<StatCard icon={<Play size={18} />} label="Aktive Sessions" value={musicStatus.sessions.length} color="success" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold">Sessions ({musicStatus.sessions.length})</h3>
|
||||
{musicStatus.sessions.length ? musicStatus.sessions.map((session) => (
|
||||
<Card key={session.guildId} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Music size={16} className="text-primary-400" />
|
||||
<span className="font-semibold text-small">{session.guildId}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Chip size="sm" variant="flat" startContent={<Repeat size={12} />}>
|
||||
{session.loop}
|
||||
</Chip>
|
||||
<Chip size="sm" variant="flat" startContent={<List size={12} />}>
|
||||
{session.queueLength} in Queue
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{session.nowPlaying ? (
|
||||
<div className="rounded-xl bg-default-100/50 px-4 py-3 text-small">
|
||||
<span className="text-default-500">Jetzt l<EFBFBD>uft: </span>
|
||||
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-primary-400 hover:underline">
|
||||
{session.nowPlaying.title}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-default-100/30 px-4 py-3 text-small text-default-400">
|
||||
Keine aktive Wiedergabe
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<Music size={24} />
|
||||
Keine aktiven Musik-Sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
77
frontend/src/pages/ReactionRoles.tsx
Normal file
77
frontend/src/pages/ReactionRoles.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator } from '@heroui/react';
|
||||
import { Tag, Save, Hash, List } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function ReactionRoles() {
|
||||
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
|
||||
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Bestehende Sets ({reactionRoles.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{reactionRoles.length ? reactionRoles.map((set, i) => (
|
||||
<Card key={set.id || i} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
|
||||
<Tag size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-small truncate">{set.title || 'Reaction Role'}</div>
|
||||
<div className="text-tiny text-default-400 truncate">Channel: {set.channelId || '-'}</div>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat">{(set.entries?.length || 0)} Eintr<EFBFBD>ge</Chip>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<Tag size={24} />
|
||||
Keine Sets
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neues Set</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input
|
||||
label="Titel"
|
||||
placeholder="Rollenauswahl"
|
||||
value={reactionDraft.title}
|
||||
onValueChange={(v) => setReactionDraft((s) => ({ ...s, title: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Channel ID"
|
||||
placeholder="Channel f<>r die Nachricht"
|
||||
value={reactionDraft.channelId}
|
||||
onValueChange={(v) => setReactionDraft((s) => ({ ...s, channelId: v }))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-small font-medium mb-1">Eintr<EFBFBD>ge</label>
|
||||
<TextArea
|
||||
placeholder="Emoji | Role ID | Label :emoji: | 123456789 | Rolle 1 :wave: | 987654321 | Rolle 2"
|
||||
minRows={6}
|
||||
value={reactionDraft.entries}
|
||||
onValueChange={(v) => setReactionDraft((s) => ({ ...s, entries: v }))}
|
||||
/>
|
||||
<p className="mt-1 text-tiny text-default-400">
|
||||
Pro Zeile: Emoji | Role ID | Label (optional) | Beschreibung (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveReactionRole}>
|
||||
Reaction Role speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
137
frontend/src/pages/Register.tsx
Normal file
137
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Tabs, Tab, Separator } from '@heroui/react';
|
||||
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
export function Register() {
|
||||
const {
|
||||
registerForms, registerApps, registerTab, setRegisterTab,
|
||||
formDraft, setFormDraft, editingFormId, setEditingFormId,
|
||||
saveForm, deleteForm, sendFormPanel
|
||||
} = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Antr<74>ge verwalten.">
|
||||
<Tabs aria-label="Register Tabs" color="primary" selectedKey={registerTab} variant="bordered" onSelectionChange={(key) => setRegisterTab(String(key))}>
|
||||
<Tab key="forms" title="Formulare" />
|
||||
<Tab key="apps" title="Antr<74>ge" />
|
||||
</Tabs>
|
||||
|
||||
{registerTab === 'forms' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Bestehende Formulare ({registerForms.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{registerForms.length ? registerForms.map((f) => (
|
||||
<Card key={f.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText size={16} className="text-primary-400 shrink-0" />
|
||||
<span className="font-semibold text-small truncate">{f.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button isIconOnly size="sm" variant="light" onPress={() => {
|
||||
setFormDraft({
|
||||
name: f.name, description: f.description || '',
|
||||
reviewChannelId: f.reviewChannelId || '',
|
||||
notifyRoleIds: (f.notifyRoleIds || []).join(', '),
|
||||
fields: (f.fields || []).map((fd) =>
|
||||
fd.label + '|' + fd.type + (fd.required ? '|required' : '') + (fd.options ? '|' + fd.options.join(',') : '')
|
||||
).join('\n')
|
||||
});
|
||||
setEditingFormId(f.id);
|
||||
}}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteForm(f.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-small text-default-400">{f.description || 'Keine Beschreibung'}</p>
|
||||
<div className="flex items-center gap-2 text-tiny">
|
||||
<Chip size="sm" variant="flat" color={f.isActive ? 'success' : 'default'}>
|
||||
{f.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Chip>
|
||||
<span className="text-default-400">{f.fields?.length || 0} Felder</span>
|
||||
<Button size="sm" variant="flat" startContent={<Send size={12} />} onPress={() => sendFormPanel(f.id)}>
|
||||
Panel senden
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<ClipboardList size={24} />
|
||||
Keine Formulare
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">{editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input label="Name" value={formDraft.name} onValueChange={(v) => setFormDraft((s) => ({ ...s, name: v }))} />
|
||||
<Input label="Beschreibung" value={formDraft.description} onValueChange={(v) => setFormDraft((s) => ({ ...s, description: v }))} />
|
||||
<Input label="Review Channel ID" value={formDraft.reviewChannelId} onValueChange={(v) => setFormDraft((s) => ({ ...s, reviewChannelId: v }))} />
|
||||
<Input label="Benachrichtigungs-Rollen (Komma-getrennt)" value={formDraft.notifyRoleIds} onValueChange={(v) => setFormDraft((s) => ({ ...s, notifyRoleIds: v }))} />
|
||||
<TextArea label="Felder (label|type|required|options)" minRows={6} value={formDraft.fields} onValueChange={(v) => setFormDraft((s) => ({ ...s, fields: v }))} />
|
||||
<p className="text-tiny text-default-400">
|
||||
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
|
||||
{editingFormId && (
|
||||
<Button variant="flat" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registerTab === 'apps' && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-3 text-base font-semibold">Eingegangene Antr<EFBFBD>ge ({registerApps.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{registerApps.length ? registerApps.map((app) => (
|
||||
<Card key={app.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">{app.username || app.userId}</div>
|
||||
<Chip size="sm" variant="flat" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
|
||||
{app.status}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="text-tiny text-default-400">{formatDate(app.createdAt)}</div>
|
||||
{app.answers?.length ? (
|
||||
<div className="space-y-1">
|
||||
{app.answers.map((a, i) => (
|
||||
<div key={i} className="text-small">
|
||||
<span className="text-default-500">{a.label || 'Frage'}: </span>
|
||||
{a.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<ClipboardList size={24} />
|
||||
Keine Antr<EFBFBD>ge
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
94
frontend/src/pages/ServerStats.tsx
Normal file
94
frontend/src/pages/ServerStats.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { Activity, Save, Trash2, Plus, BarChart3 } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function ServerStats() {
|
||||
const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp();
|
||||
|
||||
const items = (statsDraft?.items || []);
|
||||
|
||||
return (
|
||||
<SectionCard title="Server Stats" subtitle="Counter und Refresh-Intervall steuern">
|
||||
<div className="grid gap-5 xl:grid-cols-[420px_1fr]">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={statsDraft?.enabled === true} onValueChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}>
|
||||
<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Kategorie-Name"
|
||||
placeholder="?? Server Stats"
|
||||
value={statsDraft?.categoryName || ''}
|
||||
onValueChange={(v) => setStatsDraft((s) => ({ ...(s || {}), categoryName: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Refresh (Minuten)"
|
||||
type="number"
|
||||
value={String(statsDraft?.refreshMinutes || 10)}
|
||||
onValueChange={(v) => setStatsDraft((s) => ({ ...(s || {}), refreshMinutes: Number(v || 10) }))}
|
||||
/>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveServerStats}>
|
||||
Server Stats speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Items ({items.length})</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
{items.length ? items.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={14} className="text-primary-400" />
|
||||
<span className="font-medium">{item.label || item.key}</span>
|
||||
<Chip size="sm" variant="flat">{item.type || '-'}</Chip>
|
||||
</div>
|
||||
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatsItem(i)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<BarChart3 size={20} />
|
||||
Keine Items
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-small font-semibold mb-2">Item hinzuf<EFBFBD>gen</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Label" value={statsItemDraft.label} onValueChange={(v) => setStatsItemDraft((s) => ({ ...s, label: v }))} />
|
||||
<select
|
||||
className="w-full rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm outline-none"
|
||||
value={statsItemDraft.type}
|
||||
onChange={(e) => setStatsItemDraft((s) => ({ ...s, type: e.target.value }))}
|
||||
>
|
||||
<option value="members">Mitglieder</option>
|
||||
<option value="channels">Channels</option>
|
||||
<option value="roles">Rollen</option>
|
||||
<option value="boosts">Boosts</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<Button size="sm" color="primary" startContent={<Plus size={14} />} onPress={addStatsItem}>
|
||||
Hinzuf<EFBFBD>gen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/Settings.tsx
Normal file
81
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Card, CardContent, CardHeader, Input, Button, Switch, Separator } from '@heroui/react';
|
||||
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { settings, setSettings, saveSettingsPayload } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Allgemein</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input
|
||||
label="Welcome Channel ID"
|
||||
placeholder="Channel ID"
|
||||
value={settings.welcomeChannelId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, welcomeChannelId: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Log Channel ID"
|
||||
placeholder="Channel ID"
|
||||
value={settings.logChannelId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, logChannelId: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Support Role ID"
|
||||
placeholder="Role ID"
|
||||
value={settings.supportRoleId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, supportRoleId: v }))}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Logging Kategorien</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={settings.loggingConfig?.categories?.joinLeave !== false} onValueChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), joinLeave: v } } }))}>
|
||||
<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div>
|
||||
</Switch>
|
||||
|
||||
<Switch isSelected={settings.loggingConfig?.categories?.messageEdit !== false} onValueChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageEdit: v } } }))}>
|
||||
<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div>
|
||||
</Switch>
|
||||
|
||||
<Switch isSelected={settings.loggingConfig?.categories?.messageDelete !== false} onValueChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageDelete: v } } }))}>
|
||||
<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div>
|
||||
</Switch>
|
||||
|
||||
<Switch isSelected={settings.loggingConfig?.categories?.automodActions !== false} onValueChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), automodActions: v } } }))}>
|
||||
<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div>
|
||||
</Switch>
|
||||
|
||||
<Switch isSelected={settings.loggingConfig?.categories?.ticketActions !== false} onValueChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), ticketActions: v } } }))}>
|
||||
<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div>
|
||||
</Switch>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/Statuspage.tsx
Normal file
93
frontend/src/pages/Statuspage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { RadioTower, Save, Trash2, Plus, Activity as ActivityIcon } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import type { StatusService } from '../types';
|
||||
|
||||
export function Statuspage() {
|
||||
const { statusDraft, setStatusDraft, saveStatuspage, statusServiceDraft, setStatusServiceDraft, addStatusService, deleteStatusService } = useApp();
|
||||
|
||||
const services = ((statusDraft?.services || []) as StatusService[]);
|
||||
|
||||
return (
|
||||
<SectionCard title="Statuspage" subtitle="Statusseite und Service-Liste verwalten">
|
||||
<div className="grid gap-5 xl:grid-cols-[420px_1fr]">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={statusDraft?.enabled !== false} onValueChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}>
|
||||
<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Channel ID"
|
||||
placeholder="Channel f<>r Status-Updates"
|
||||
value={statusDraft?.channelId || ''}
|
||||
onValueChange={(v) => setStatusDraft((s) => ({ ...(s || {}), channelId: v }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Intervall (ms)"
|
||||
type="number"
|
||||
value={String(statusDraft?.intervalMs || 60000)}
|
||||
onValueChange={(v) => setStatusDraft((s) => ({ ...(s || {}), intervalMs: Number(v || 60000) }))}
|
||||
/>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveStatuspage}>
|
||||
Statuspage speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Services ({services.length})</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
{services.length ? services.map((service) => (
|
||||
<div key={service.id} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-2 rounded-full shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{service.name || 'Service'}</span>
|
||||
<span className="ml-2 text-default-400 text-tiny truncate">{service.url || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" variant="flat" color={
|
||||
service.status === 'operational' ? 'success' :
|
||||
service.status === 'degraded' ? 'warning' :
|
||||
service.status === 'down' ? 'danger' : 'default'
|
||||
}>{service.status || 'unknown'}</Chip>
|
||||
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatusService(service.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<ActivityIcon size={20} />
|
||||
Keine Services
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-small font-semibold mb-2">Service hinzuf<EFBFBD>gen</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Name" value={statusServiceDraft.name} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, name: v }))} />
|
||||
<Input placeholder="URL (optional)" value={statusServiceDraft.url} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, url: v }))} />
|
||||
<Button size="sm" color="primary" startContent={<Plus size={14} />} onPress={addStatusService}>
|
||||
Hinzuf<EFBFBD>gen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/SupportLogin.tsx
Normal file
119
frontend/src/pages/SupportLogin.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { LogIn, UserRound, Eye, Save, Send, RefreshCw } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function SupportLogin() {
|
||||
const { supportLogin, setSupportLogin, saveSupportLogin } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Support Login" subtitle="Login-Panel f<>r Supporter konfigurieren">
|
||||
<div className="grid gap-5 xl:grid-cols-[1fr_400px]">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Panel-Konfiguration</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch
|
||||
isSelected={supportLogin?.config?.autoRefresh !== false}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
|
||||
>
|
||||
Auto-Refresh aktiv
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Panel Channel ID"
|
||||
placeholder="Channel ID eingeben"
|
||||
value={supportLogin?.config?.panelChannelId || ''}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: v } } : s)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Titel"
|
||||
value={supportLogin?.config?.title || 'Support Login'}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: v } } : s)}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Beschreibung"
|
||||
value={supportLogin?.config?.description || ''}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: v } } : s)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Login Button Label"
|
||||
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: v } } : s)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Logout Button Label"
|
||||
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
|
||||
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: v } } : s)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveSupportLogin}>
|
||||
Speichern & Panel senden
|
||||
</Button>
|
||||
<Button variant="flat" startContent={<Send size={16} />}>
|
||||
Panel manuell senden
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Live Vorschau</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="rounded-xl border border-default-100 bg-gradient-to-b from-default-50/40 to-background p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
|
||||
<LogIn size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{supportLogin?.config?.title || 'Support Login'}</div>
|
||||
<div className="text-tiny text-default-400">{supportLogin?.config?.description || 'Melde dich als Support an/ab.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" color="primary" variant="flat">{supportLogin?.config?.loginLabel || 'Login'}</Button>
|
||||
<Button size="sm" variant="flat">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Aktive Supporter</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="size-2 rounded-full bg-success-400" />
|
||||
<Avatar name={s.username?.[0]} size="sm" className="size-6" />
|
||||
<span className="font-medium">{s.username || s.userId}</span>
|
||||
<Chip size="sm" variant="flat" color="success" className="ml-auto">Online</Chip>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<UserRound size={20} />
|
||||
Keine aktiven Supporter
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-tiny text-default-400">
|
||||
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
346
frontend/src/pages/Tickets.tsx
Normal file
346
frontend/src/pages/Tickets.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator } from '@heroui/react';
|
||||
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
import { EmptyState } from '../components/shared/EmptyState';
|
||||
|
||||
export function Tickets() {
|
||||
const {
|
||||
tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
|
||||
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
|
||||
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
|
||||
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
|
||||
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
|
||||
updateAutomation, deleteAutomation, overview
|
||||
} = useApp();
|
||||
|
||||
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
|
||||
|
||||
return (
|
||||
<SectionCard title="Ticketsystem" subtitle="Ticket-Übersicht, Pipeline, SLA, Automationen und Knowledge Base.">
|
||||
<Tabs aria-label="Ticket Tabs" color="primary" 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 space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<StatCard icon={<Ticket size={18} />} label="Offen" value={overview?.tickets?.open ?? 0} color="warning" />
|
||||
<StatCard icon={<Clock size={18} />} label="In Bearbeitung" value={overview?.tickets?.inProgress ?? 0} color="primary" />
|
||||
<StatCard icon={<CheckCircle size={18} />} label="Geschlossen" value={overview?.tickets?.closed ?? 0} color="default" />
|
||||
<StatCard icon={<UserRound size={18} />} label="Gesamt" value={tickets.length} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Offene Tickets ({openTickets.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{openTickets.length ? openTickets.map((t) => (
|
||||
<Card key={t.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`size-2 rounded-full shrink-0 ${
|
||||
t.status === 'open' ? 'bg-warning-400' :
|
||||
t.status === 'in-progress' ? 'bg-primary-400' :
|
||||
t.status === 'waiting' ? 'bg-default-400' : 'bg-success-400'
|
||||
}`} />
|
||||
<span className="font-semibold text-small truncate">{t.topic || 'Ticket'}</span>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
|
||||
{t.status || 'open'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-tiny text-default-400">
|
||||
<span>{t.category || 'Allgemein'}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(t.createdAt)}</span>
|
||||
{t.claimedById && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Chip size="sm" variant="flat" color="success" className="h-5">Claimed</Chip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
aria-label="Status"
|
||||
className="w-40 rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary-400"
|
||||
value=""
|
||||
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
|
||||
>
|
||||
<option value="">Status ändern</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="waiting">Warten</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => closeTicket(t.id)}>Schließen</Button>
|
||||
<Button size="sm" variant="flat" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : <EmptyState message="Keine offenen Tickets" icon={<Ticket size={24} />} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Alle Tickets ({tickets.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{tickets.length ? tickets.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/20 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-small font-medium truncate">{t.topic || t.id}</div>
|
||||
<div className="text-tiny text-default-400">{t.category || '-'} · {formatDate(t.createdAt)}</div>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
|
||||
{t.status}
|
||||
</Chip>
|
||||
</div>
|
||||
)) : <EmptyState message="Keine Tickets" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticketDetail && (
|
||||
<Card className="border border-default-100">
|
||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||
<h3 className="text-lg font-bold">{ticketDetail.topic || 'Ticket-Details'}</h3>
|
||||
<Button size="sm" variant="light" onPress={() => setTicketDetail(null)}>Schließen</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<div className="grid grid-cols-3 gap-3 text-small">
|
||||
<div className="rounded-lg bg-default-100/40 px-3 py-2">
|
||||
<span className="text-default-400">Status:</span> {ticketDetail.status}
|
||||
</div>
|
||||
<div className="rounded-lg bg-default-100/40 px-3 py-2">
|
||||
<span className="text-default-400">Priorität:</span> {ticketDetail.priority || 'normal'}
|
||||
</div>
|
||||
<div className="rounded-lg bg-default-100/40 px-3 py-2">
|
||||
<span className="text-default-400">Kategorie:</span> {ticketDetail.category || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-2 text-small font-semibold">Nachrichten ({ticketMessages.length})</h4>
|
||||
<div className="max-h-60 space-y-2 overflow-auto">
|
||||
{ticketMessages.length ? ticketMessages.map((msg, i) => (
|
||||
<div key={i} className="rounded-lg bg-default-100/40 px-3 py-2 text-small">
|
||||
<span className="text-tiny text-default-400">{msg.author?.tag || msg.authorId}: </span>
|
||||
{msg.content || '(Embed)'}
|
||||
</div>
|
||||
)) : <p className="text-tiny text-default-400">Keine Nachrichten</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticketTab === 'pipeline' && (
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
|
||||
{[
|
||||
{ key: 'neu', label: 'Neu', color: 'warning' as const },
|
||||
{ key: 'in_bearbeitung', label: 'In Bearbeitung', color: 'primary' as const },
|
||||
{ key: 'warten_auf_user', label: 'Warten auf User', color: 'default' as const },
|
||||
{ key: 'erledigt', label: 'Erledigt', color: 'success' as const },
|
||||
].map(({ key, label, color }) => (
|
||||
<Card key={key} className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-4 pt-4 pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`size-2.5 rounded-full bg-${color}-400`} />
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<Chip size="sm" variant="flat" color={color}>{(pipeline[key] || []).length}</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-4">
|
||||
{(pipeline[key] || []).length ? (pipeline[key] || []).map((t) => (
|
||||
<div key={t.id} className="rounded-xl border border-default-100 bg-default-50/30 p-3 text-small">
|
||||
<div className="font-medium truncate">{t.topic || t.id}</div>
|
||||
<div className="mt-1 text-tiny text-default-400">{formatDate(t.createdAt)}</div>
|
||||
<Button size="sm" variant="flat" className="mt-2 h-6 min-w-0 px-2 text-tiny" onPress={() => {
|
||||
const nextStatus = key === 'neu' ? 'in-progress' : key === 'in_bearbeitung' ? 'waiting' : key === 'warten_auf_user' ? 'closed' : 'closed';
|
||||
updateTicketStatus(t.id, nextStatus);
|
||||
}}>
|
||||
{key === 'erledigt' ? 'Schließen' : key === 'warten_auf_user' ? 'Schließen' : 'Weiter'}
|
||||
</Button>
|
||||
</div>
|
||||
)) : <p className="text-tiny text-default-400 text-center py-4">Keine Tickets</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticketTab === 'sla' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">SLA pro Supporter</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{(sla.supporters || []).length ? sla.supporters.map((row: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRound size={14} className="text-default-400" />
|
||||
<span className="font-medium">{row.supporterId || '-'}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-tiny text-default-500">
|
||||
<span>{row.tickets || 0} Tickets</span>
|
||||
<span>TTC: {row.avgTTC ? `${Math.round(row.avgTTC / 1000)}s` : '-'}</span>
|
||||
<span>TTFR: {row.avgTTFR ? `${Math.round(row.avgTTFR / 1000)}s` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-tiny text-default-400 text-center py-4">Keine Daten</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">SLA pro Tag</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{(sla.days || []).length ? sla.days.slice(-14).map((row: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-2 text-tiny">
|
||||
<span className="font-medium">{row.date || '-'}</span>
|
||||
<div className="flex gap-2 text-default-500">
|
||||
<span>{row.tickets || 0}</span>
|
||||
<span>TTC: {row.avgTTC ? `${Math.round(row.avgTTC / 1000)}s` : '-'}</span>
|
||||
<span>TTFR: {row.avgTTFR ? `${Math.round(row.avgTTFR / 1000)}s` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-tiny text-default-400 text-center py-4">Keine Daten</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticketTab === 'automations' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Regeln ({automations.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{automations.length ? automations.map((rule) => (
|
||||
<Card key={rule.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-small">{rule.name || 'Automation'}</div>
|
||||
<Chip size="sm" variant="flat" color={rule.active !== false ? 'success' : 'default'}>
|
||||
{rule.active !== false ? 'Aktiv' : 'Inaktiv'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteAutomation(rule.id)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : <EmptyState message="Keine Regeln" icon={<FileText size={24} />} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{automationEditDraft ? (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Automation bearbeiten</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input label="Name" value={automationEditDraft.name} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, name: v }))} />
|
||||
<Input label="Kategorie / Zustand" value={automationEditDraft.conditionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, conditionValue: v }))} />
|
||||
<TextArea label="Aktion / Nachricht" value={automationEditDraft.actionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, actionValue: v }))} />
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="flat" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neue Automation</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input label="Name" value={automationDraft.name} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, name: v }))} />
|
||||
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, conditionValue: v }))} />
|
||||
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, actionValue: v }))} />
|
||||
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticketTab === 'kb' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Artikel ({kbArticles.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{kbArticles.length ? kbArticles.map((article) => (
|
||||
<Card key={article.id} className="border border-default-100 bg-default-50/20">
|
||||
<CardContent className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-small truncate">{article.title || 'Artikel'}</div>
|
||||
<Chip size="sm" variant="flat">{(article.keywords?.length || 0)} Keywords</Chip>
|
||||
</div>
|
||||
<p className="text-small text-default-400 line-clamp-2">{article.content || '-'}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteKbArticle(article.id)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : <EmptyState message="Keine Artikel" icon={<FileText size={24} />} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{kbEditDraft ? (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Artikel bearbeiten</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input label="Titel" value={kbEditDraft.title} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, title: v }))} />
|
||||
<Input label="Keywords" value={kbEditDraft.keywords} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, keywords: v }))} />
|
||||
<TextArea label="Inhalt" minRows={5} value={kbEditDraft.content} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, content: v }))} />
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="flat" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neuer KB-Artikel</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Input label="Titel" value={kbDraft.title} onValueChange={(v) => setKbDraft((s) => ({ ...s, title: v }))} />
|
||||
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(v) => setKbDraft((s) => ({ ...s, keywords: v }))} />
|
||||
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(v) => setKbDraft((s) => ({ ...s, content: v }))} />
|
||||
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
86
frontend/src/pages/Welcome.tsx
Normal file
86
frontend/src/pages/Welcome.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator } from '@heroui/react';
|
||||
import { Sparkles, Save, Eye } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
|
||||
export function Welcome() {
|
||||
const { settings, setSettings, saveSettingsPayload } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Welcome konfigurieren</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<Switch isSelected={settings.welcomeConfig?.enabled !== false} onValueChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}>
|
||||
<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div>
|
||||
</Switch>
|
||||
|
||||
<Input
|
||||
label="Channel ID"
|
||||
placeholder="Channel ID f<>r Willkommensnachrichten"
|
||||
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: v } }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Titel"
|
||||
placeholder="Willkommen {user}!"
|
||||
value={settings.welcomeConfig?.embedTitle || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedTitle: v } }))}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Beschreibung"
|
||||
placeholder="Beschreibung des Embeds"
|
||||
value={settings.welcomeConfig?.embedDescription || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedDescription: v } }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Footer"
|
||||
placeholder={new Date().getFullYear().toString()}
|
||||
value={settings.welcomeConfig?.embedFooter || ''}
|
||||
onValueChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedFooter: v } }))}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Live Vorschau</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="rounded-xl border border-default-100 bg-gradient-to-b from-default-50/40 to-background p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-lg">{settings.welcomeConfig?.embedTitle || 'Willkommen!'}</div>
|
||||
<p className="mt-1 text-small text-default-500">{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
|
||||
{settings.welcomeConfig?.embedFooter && (
|
||||
<p className="mt-2 text-tiny text-default-400">{settings.welcomeConfig.embedFooter}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-tiny text-default-400">
|
||||
Nutze {'{user}'} f<EFBFBD>r den Benutzernamen und {'{server}'} f<EFBFBD>r den Servernamen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user