migrate dashboard to real HeroUI v3 components and neutral theme
The frontend was built against HeroUI v2 props/classes (variant="light"/"flat", color="primary", startContent, text-tiny, <Avatar src=/name=>) while @heroui/react ^3.2.1 is installed, so most styling was silently ignored. Rewrites every page/component onto the actual v3 API (variant/color enums, Tooltip/Dropdown trigger-content structure, Avatar.Image/Fallback via a new AppAvatar helper) and replaces the custom orange branding with HeroUI's neutral default theme tokens. Also compacts the dashboard stat tiles, narrows the sidebar, and swaps the activity stat grid for a single-hue comparison bar chart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,22 +3,6 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--app-bg: #110f0d;
|
||||
--app-bg-soft: #181513;
|
||||
--app-panel: #1d1a1f;
|
||||
--app-panel-strong: #221e24;
|
||||
--app-panel-muted: #171419;
|
||||
--app-border: rgba(255, 255, 255, 0.08);
|
||||
--app-border-strong: rgba(255, 166, 77, 0.22);
|
||||
--app-text: #f6f2ee;
|
||||
--app-text-muted: #b8aca1;
|
||||
--app-accent: #ff8a3d;
|
||||
--app-accent-strong: #ff6a00;
|
||||
--app-accent-soft: rgba(255, 138, 61, 0.14);
|
||||
--app-accent-glow: rgba(255, 138, 61, 0.28);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -26,11 +10,8 @@ html, body, #root {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 106, 0, 0.12), transparent 22%),
|
||||
radial-gradient(circle at top right, rgba(255, 138, 61, 0.08), transparent 18%),
|
||||
linear-gradient(180deg, #0f0d0c 0%, #141110 100%);
|
||||
color: var(--app-text);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -41,137 +22,37 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: inherit;
|
||||
border: var(--border-width-field) solid var(--field-border, var(--border));
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--field-background, var(--default));
|
||||
color: var(--field-foreground, var(--foreground));
|
||||
font: inherit;
|
||||
padding: 0.85rem 1rem;
|
||||
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
|
||||
padding: 0.6rem 0.85rem;
|
||||
transition: border-color 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
textarea:hover,
|
||||
select:hover {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: var(--field-hover);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-accent);
|
||||
box-shadow: 0 0 0 3px rgba(255, 138, 61, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--field-border-focus);
|
||||
background: var(--field-focus);
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
background: linear-gradient(180deg, rgba(31, 27, 33, 0.96), rgba(23, 20, 25, 0.96));
|
||||
border: 1px solid var(--app-border);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.surface-card-strong {
|
||||
background: linear-gradient(180deg, rgba(36, 30, 24, 0.98), rgba(25, 21, 18, 0.98));
|
||||
border: 1px solid var(--app-border-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 18px 50px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.surface-card-muted {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--app-accent);
|
||||
}
|
||||
|
||||
.accent-chip {
|
||||
background: var(--app-accent-soft);
|
||||
color: #ffb37f;
|
||||
border: 1px solid rgba(255, 138, 61, 0.18);
|
||||
}
|
||||
|
||||
.accent-button {
|
||||
background: linear-gradient(135deg, var(--app-accent) 0%, var(--app-accent-strong) 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 166, 77, 0.45);
|
||||
box-shadow: 0 10px 24px rgba(255, 106, 0, 0.18);
|
||||
}
|
||||
|
||||
.accent-button:hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.accent-button-soft {
|
||||
background: var(--app-accent-soft);
|
||||
color: #ffbc8e;
|
||||
border: 1px solid rgba(255, 138, 61, 0.18);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255, 138, 61, 0.08);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.nav-button-active {
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.22), rgba(255, 106, 0, 0.24));
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 138, 61, 0.28);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-shell {
|
||||
background: linear-gradient(180deg, rgba(16, 14, 13, 0.98), rgba(18, 15, 14, 0.98));
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: linear-gradient(135deg, var(--app-accent) 0%, var(--app-accent-strong) 100%);
|
||||
box-shadow: 0 12px 30px rgba(255, 106, 0, 0.22);
|
||||
}
|
||||
|
||||
.section-subtle {
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
@@ -180,6 +61,6 @@ label {
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner color="primary" label="Dashboard wird geladen..." size="lg" />
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3">
|
||||
<Spinner color="accent" size="lg" />
|
||||
<p className="text-sm text-muted">Dashboard wird geladen...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +18,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!guilds.length) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-default-500">Keine Server verfügbar</p>
|
||||
<p className="text-muted">Keine Server verfügbar</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button, Chip, Tooltip, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Avatar } from '@heroui/react';
|
||||
import { Button, Chip, Tooltip, Dropdown, DropdownTrigger, DropdownPopover, DropdownMenu, DropdownItem } from '@heroui/react';
|
||||
import { Moon, Sun, ChevronDown, LogOut, Settings } from 'lucide-react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { AppAvatar } from '../shared/AppAvatar';
|
||||
|
||||
const navLabels: Record<string, string> = {
|
||||
overview: 'Übersicht',
|
||||
@@ -30,14 +31,14 @@ export function Header() {
|
||||
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 section-subtle min-w-0">
|
||||
<button className="hover:text-white transition-colors" onClick={() => setSection('overview')}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted min-w-0">
|
||||
<button className="hover:text-foreground transition-colors" onClick={() => setSection('overview')}>
|
||||
Dashboard
|
||||
</button>
|
||||
{section !== 'overview' && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="accent-text font-semibold truncate">{navLabels[section] || section}</span>
|
||||
<span className="text-accent font-semibold truncate">{navLabels[section] || section}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -45,33 +46,36 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusMessage && (
|
||||
<Chip size="sm" variant="flat" className="accent-chip max-w-[220px]">
|
||||
<Chip size="sm" variant="soft" color="accent" className="max-w-[220px]">
|
||||
<span className="truncate">{statusMessage}</span>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Tooltip content={dark ? 'Helles Design' : 'Dunkles Design'} placement="bottom">
|
||||
<Button isIconOnly radius="lg" size="sm" variant="light" className="ghost-button" onPress={toggle}>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
<Button isIconOnly size="sm" variant="ghost" onPress={toggle}>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement="bottom">{dark ? 'Helles Design' : 'Dunkles Design'}</Tooltip.Content>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button className="ghost-button 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>
|
||||
<Dropdown>
|
||||
<DropdownTrigger className="inline-flex h-9 items-center gap-2 rounded-3xl px-3 text-sm font-medium hover:bg-default-hover">
|
||||
<AppAvatar name={user?.username} size="sm" className="size-6" />
|
||||
<span className="hidden sm:inline text-sm">{user?.username}</span>
|
||||
<ChevronDown size={14} />
|
||||
</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>
|
||||
<DropdownPopover placement="bottom end">
|
||||
<DropdownMenu aria-label="User Menu">
|
||||
<DropdownItem key="settings" onAction={() => setSection('settings')}>
|
||||
<Settings size={14} /> Einstellungen
|
||||
</DropdownItem>
|
||||
<DropdownItem key="logout" variant="danger" onAction={handleLogout}>
|
||||
<LogOut size={14} /> Abmelden
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</DropdownPopover>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar, Button, Card, CardContent, ScrollShadow, Tooltip,
|
||||
Button, Card, CardContent, ScrollShadow, Tooltip,
|
||||
Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem
|
||||
} from '@heroui/react';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Shield, Sparkles, Tag, Ticket, Wrench
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { AppAvatar } from '../shared/AppAvatar';
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
@@ -63,13 +64,13 @@ export function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar-shell flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-20' : 'w-72'}`}>
|
||||
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-20' : 'w-60'}`}>
|
||||
<div className={`flex items-center gap-3 px-4 pt-4 pb-3 ${collapsed ? 'justify-center' : ''}`}>
|
||||
<div className="brand-mark flex size-10 items-center justify-center rounded-2xl font-black text-white">P</div>
|
||||
<div className="bg-accent text-accent-foreground flex size-10 items-center justify-center rounded-2xl font-black">P</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-bold">Papo</div>
|
||||
<div className="text-[10px] uppercase tracking-widest section-subtle">Dashboard</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-muted">Dashboard</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,7 +103,7 @@ export function Sidebar() {
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
{!collapsed && (
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] section-subtle">
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
@@ -112,17 +113,18 @@ export function Sidebar() {
|
||||
.map((item) => {
|
||||
const isActive = section === item.key;
|
||||
return (
|
||||
<Tooltip key={item.key} content={collapsed ? item.label : ''} placement="right" offset={8}>
|
||||
<Button
|
||||
className={`nav-button h-10 justify-start gap-3 px-3 font-medium ${isActive ? 'nav-button-active' : ''}`}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={item.icon}
|
||||
onPress={() => setSection(item.key)}
|
||||
>
|
||||
{!collapsed && item.label}
|
||||
</Button>
|
||||
<Tooltip key={item.key} isDisabled={!collapsed}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
className={`h-10 justify-start gap-3 px-3 font-medium ${isActive ? 'bg-accent-soft text-accent-soft-foreground' : 'text-muted'}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => setSection(item.key as any)}
|
||||
>
|
||||
{item.icon} {!collapsed && item.label}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement="right" offset={8}>{item.label}</Tooltip.Content>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
@@ -132,21 +134,22 @@ export function Sidebar() {
|
||||
{user?.isAdmin && (
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] section-subtle">
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Admin
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={collapsed ? 'Admin' : ''} placement="right" offset={8}>
|
||||
<Button
|
||||
className={`nav-button h-10 justify-start gap-3 px-3 font-medium ${section === 'admin' ? 'nav-button-active' : ''}`}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<Wrench size={18} />}
|
||||
onPress={() => setSection('admin')}
|
||||
>
|
||||
{!collapsed && 'Admin'}
|
||||
</Button>
|
||||
<Tooltip isDisabled={!collapsed}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
className={`h-10 justify-start gap-3 px-3 font-medium ${section === 'admin' ? 'bg-accent-soft text-accent-soft-foreground' : 'text-muted'}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => setSection('admin' as any)}
|
||||
>
|
||||
<Wrench size={18} /> {!collapsed && 'Admin'}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement="right" offset={8}>Admin</Tooltip.Content>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,28 +159,30 @@ export function Sidebar() {
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="ghost-button w-full"
|
||||
radius="lg"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
variant="ghost"
|
||||
onPress={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
||||
</Button>
|
||||
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardContent className={`flex items-center gap-3 p-2 ${collapsed ? 'justify-center' : ''}`}>
|
||||
<Avatar name={user?.username} size="sm" className="shrink-0" />
|
||||
<AppAvatar 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="text-[10px] section-subtle">Angemeldet</div>
|
||||
<div className="text-[10px] text-muted">Angemeldet</div>
|
||||
</div>
|
||||
<Tooltip content="Abmelden" placement="top">
|
||||
<Button isIconOnly radius="lg" size="sm" variant="light" className="ghost-button" onPress={handleLogout}>
|
||||
<LogOut size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
<Button isIconOnly size="sm" variant="ghost" onPress={handleLogout}>
|
||||
<LogOut size={14} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement="top">Abmelden</Tooltip.Content>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -20,13 +20,13 @@ export function Topbar({ guildName, statusMessage, children }: Props) {
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{guildName}</h1>
|
||||
<p className="mt-1 text-small text-default-500">Bot-Dashboard Verwaltung</p>
|
||||
<p className="mt-1 text-sm text-muted">Bot-Dashboard Verwaltung</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{statusMessage && (
|
||||
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
|
||||
<Chip color="warning" size="sm" variant="soft">{statusMessage}</Chip>
|
||||
)}
|
||||
<Button isIconOnly radius="lg" size="sm" variant="light" onPress={() => setDark((d) => !d)}>
|
||||
<Button isIconOnly size="sm" variant="ghost" onPress={() => setDark((d) => !d)}>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
{children}
|
||||
|
||||
@@ -12,8 +12,8 @@ export function ActivityTile({ icon, label, value }: Props) {
|
||||
<Card>
|
||||
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Chip color="primary" size="sm" variant="flat" startContent={icon}>
|
||||
{label}
|
||||
<Chip color="accent" size="sm" variant="soft">
|
||||
{icon} {label}
|
||||
</Chip>
|
||||
<div>
|
||||
<div className="text-2xl font-black">{value}</div>
|
||||
|
||||
17
frontend/src/components/shared/AppAvatar.tsx
Normal file
17
frontend/src/components/shared/AppAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Avatar } from '@heroui/react';
|
||||
|
||||
type Props = {
|
||||
src?: string;
|
||||
name?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppAvatar({ src, name, size = 'md', className }: Props) {
|
||||
return (
|
||||
<Avatar size={size} className={className}>
|
||||
{src && <Avatar.Image src={src} alt={name || 'Avatar'} />}
|
||||
<Avatar.Fallback>{name?.trim()?.[0]?.toUpperCase() || '?'}</Avatar.Fallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/shared/BarComparisonChart.tsx
Normal file
29
frontend/src/components/shared/BarComparisonChart.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
type Item = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
export function BarComparisonChart({ items }: Props) {
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-24 shrink-0 truncate text-xs text-muted">{item.label}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-default">
|
||||
<div
|
||||
className="h-2 rounded-r-full bg-accent"
|
||||
style={{ width: `${Math.max(2, (item.value / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-xs font-semibold tabular-nums">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ type Props = {
|
||||
|
||||
export function EmptyState({ message = 'Keine Daten vorhanden', icon }: Props) {
|
||||
return (
|
||||
<Card className="border border-default-100 bg-default-50/10">
|
||||
<Card className="bg-surface-tertiary">
|
||||
<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">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-default-soft text-muted">
|
||||
{icon || <Inbox size={24} />}
|
||||
</div>
|
||||
<p className="text-small text-default-400">{message}</p>
|
||||
<p className="text-sm text-muted">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,14 @@ type Props = {
|
||||
|
||||
export function ErrorState({ message = 'Ein Fehler ist aufgetreten', onRetry }: Props) {
|
||||
return (
|
||||
<Card className="border border-danger-100/30 bg-danger-50/10">
|
||||
<Card className="border border-danger-soft bg-danger-soft/40">
|
||||
<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">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-danger-soft text-danger">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<p className="text-small text-default-500">{message}</p>
|
||||
<p className="text-sm text-muted">{message}</p>
|
||||
{onRetry && (
|
||||
<button className="flex items-center gap-1 text-tiny text-primary-400 hover:text-primary-300 transition-colors" onClick={onRetry}>
|
||||
<button className="flex items-center gap-1 text-xs text-accent hover:opacity-80 transition-opacity" onClick={onRetry}>
|
||||
<RefreshCw size={12} /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
|
||||
export function ListPanel({ title, children, className }: Props) {
|
||||
return (
|
||||
<Card className={className ?? ''}>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import { Card, CardContent, Skeleton } from '@heroui/react';
|
||||
|
||||
type Props = {
|
||||
lines?: number;
|
||||
@@ -8,10 +8,10 @@ export function LoadingSkeleton({ lines = 3 }: Props) {
|
||||
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-5">
|
||||
<div className="h-4 w-3/4 rounded-lg bg-default-200/50" />
|
||||
{i < 2 && <div className="mt-3 h-3 w-1/2 rounded-lg bg-default-200/30" />}
|
||||
<Card key={i}>
|
||||
<CardContent className="gap-2 p-5">
|
||||
<Skeleton className="h-4 w-3/4 rounded-lg" />
|
||||
{i < 2 && <Skeleton className="mt-3 h-3 w-1/2 rounded-lg" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -10,11 +10,11 @@ type Props = {
|
||||
|
||||
export function SectionCard({ title, subtitle, children, action }: Props) {
|
||||
return (
|
||||
<Card className="surface-card-strong">
|
||||
<Card className="bg-surface-secondary">
|
||||
<CardHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{subtitle && <CardDescription className="section-subtle">{subtitle}</CardDescription>}
|
||||
{subtitle && <CardDescription>{subtitle}</CardDescription>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, Chip } from '@heroui/react';
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
@@ -6,27 +6,33 @@ type Props = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'default';
|
||||
color?: 'accent' | 'success' | 'warning' | 'danger' | 'default';
|
||||
};
|
||||
|
||||
export function StatCard({ icon, label, value, trend, color = 'primary' }: Props) {
|
||||
const chipColor = color === 'default' ? 'default' : color;
|
||||
const iconClasses: Record<NonNullable<Props['color']>, string> = {
|
||||
accent: 'bg-accent-soft text-accent-soft-foreground',
|
||||
success: 'bg-success-soft text-success-soft-foreground',
|
||||
warning: 'bg-warning-soft text-warning-soft-foreground',
|
||||
danger: 'bg-danger-soft text-danger-soft-foreground',
|
||||
default: 'bg-default-soft text-muted',
|
||||
};
|
||||
|
||||
export function StatCard({ icon, label, value, trend, color = 'default' }: Props) {
|
||||
return (
|
||||
<Card className="surface-card">
|
||||
<CardContent className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Chip color={chipColor} size="sm" variant="flat" startContent={icon} className={color === 'primary' ? 'accent-chip' : undefined}>
|
||||
{label}
|
||||
</Chip>
|
||||
{trend && (
|
||||
<span className={`text-tiny font-medium ${trend.startsWith('+') ? 'text-success-400' : trend.startsWith('-') ? 'text-danger-400' : 'text-default-400'}`}>
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="flex flex-row items-center gap-3 p-3">
|
||||
<div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${iconClasses[color]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-tight">{value}</div>
|
||||
<div className="text-tiny section-subtle">{label}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-bold leading-tight tracking-tight">{value}</div>
|
||||
</div>
|
||||
{trend && (
|
||||
<span className={`shrink-0 text-xs font-medium ${trend.startsWith('+') ? 'text-success' : trend.startsWith('-') ? 'text-danger' : 'text-muted'}`}>
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -10,55 +10,55 @@ export function Admin() {
|
||||
if (!user?.isAdmin) return null;
|
||||
|
||||
return (
|
||||
<SectionCard title="Admin" subtitle="Bot-weite <EFBFBD>bersichten">
|
||||
<SectionCard title="Admin" subtitle="Bot-weite Übersichten">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={14} className="text-primary-400" />
|
||||
<span className="text-default-500">Guilds</span>
|
||||
<Server size={14} className="text-accent" />
|
||||
<span className="text-muted">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="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={14} className="text-success-400" />
|
||||
<span className="text-default-500">Aktive Guilds (24h)</span>
|
||||
<Activity size={14} className="text-success" />
|
||||
<span className="text-muted">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="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-warning-400" />
|
||||
<span className="text-default-500">Uptime</span>
|
||||
<Clock size={14} className="text-warning" />
|
||||
<span className="text-muted">Uptime</span>
|
||||
</div>
|
||||
<span className="font-semibold">{admin.overview?.uptime ?? '-'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 size="sm" variant="soft" color="warning">
|
||||
{(admin.logs || []).length} Einträ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 key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
|
||||
<Terminal size={14} className="mt-0.5 text-muted 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>
|
||||
<p className="text-muted">{log.message || '-'}</p>
|
||||
<p className="text-xs text-muted 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">
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<Terminal size={20} />
|
||||
Keine Logs
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function Automod() {
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Filter konfigurieren</h3>
|
||||
</CardHeader>
|
||||
@@ -35,7 +35,7 @@ export function Automod() {
|
||||
<TextField>
|
||||
<Label>Log Channel ID</Label>
|
||||
<Input
|
||||
placeholder="Channel ID f<EFBFBD>r Logs"
|
||||
placeholder="Channel ID für Logs"
|
||||
value={settings.automodConfig?.logChannelId || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: e.target.value } }))}
|
||||
/>
|
||||
@@ -52,29 +52,29 @@ export function Automod() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: settings.automodConfig || {} }, 'Automod gespeichert')}>
|
||||
Speichern
|
||||
<Button variant="primary" onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: settings.automodConfig || {} }, 'Automod gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
|
||||
<p className="text-muted">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 className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
|
||||
<p className="text-muted">Bad-Word-Filter entfernt Nachrichten mit unerwü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 className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
|
||||
<p className="text-muted">Link-Filter blockiert bekannte schä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 className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
|
||||
<p className="text-muted">Spam-Filter erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Card, CardContent, CardHeader, Input, InputGroup, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
|
||||
import { CalendarDays, Save, Cake, Clock } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
@@ -7,9 +7,9 @@ export function Birthday() {
|
||||
const { birthday, setBirthday, saveBirthday } = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Eintr<EFBFBD>ge">
|
||||
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Einträge">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
@@ -21,7 +21,7 @@ export function Birthday() {
|
||||
<TextField>
|
||||
<Label>Channel ID</Label>
|
||||
<Input
|
||||
placeholder="Channel f<EFBFBD>r Geburtstagsnachrichten"
|
||||
placeholder="Channel für Geburtstagsnachrichten"
|
||||
value={birthday.config?.channelId || ''}
|
||||
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: e.target.value } }))}
|
||||
/>
|
||||
@@ -29,14 +29,16 @@ export function Birthday() {
|
||||
|
||||
<TextField>
|
||||
<Label>Sendezeit (Stunde)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={String(birthday.config?.sendHour ?? 9)}
|
||||
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, sendHour: Number(e.target.value || 0) } }))}
|
||||
startContent={<Clock size={16} className="text-default-400" />}
|
||||
/>
|
||||
<InputGroup>
|
||||
<InputGroup.Prefix><Clock size={16} className="text-muted" /></InputGroup.Prefix>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={String(birthday.config?.sendHour ?? 9)}
|
||||
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, sendHour: Number(e.target.value || 0) } }))}
|
||||
/>
|
||||
</InputGroup>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
@@ -50,29 +52,29 @@ export function Birthday() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveBirthday}>Speichern</Button>
|
||||
<Button variant="primary" onPress={saveBirthday}><Save size={16} /> Speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cake size={14} className="text-primary-400" />
|
||||
<Cake size={14} className="text-accent" />
|
||||
<span className="font-medium">{entry.userId}</span>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color="primary">
|
||||
<Chip size="sm" variant="soft" color="accent">
|
||||
{String(entry.birthDate || '').replace(/^--/, '')}
|
||||
</Chip>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<CalendarDays size={20} />
|
||||
Keine Eintr<EFBFBD>ge
|
||||
Keine Einträge
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, CardHeader, Avatar, Chip, Button, ScrollShadow } from '@heroui/react';
|
||||
import { Card, CardContent, CardHeader, Chip, Button, ScrollShadow } from '@heroui/react';
|
||||
import {
|
||||
Bot, CalendarDays, Users, Ticket, Shield, MessageSquare,
|
||||
ChevronRight, Activity, Clock, ArrowUpRight, RefreshCw, Send,
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { formatDate, guildIconUrl } from '../utils/formatters';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
import { AppAvatar } from '../components/shared/AppAvatar';
|
||||
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
|
||||
|
||||
export function Dashboard() {
|
||||
const { guildInfo, guilds, currentGuildId, overview, activity, logs, setSection } = useApp();
|
||||
@@ -14,29 +16,29 @@ export function Dashboard() {
|
||||
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 oeffnen', icon: <Settings size={16} />, color: 'default' as const },
|
||||
{ key: 'tickets', label: 'Ticket Panel senden', icon: <Send size={16} /> },
|
||||
{ key: 'serverstats', label: 'Sync starten', icon: <RefreshCw size={16} /> },
|
||||
{ key: 'modules', label: 'Module aktualisieren', icon: <Zap size={16} /> },
|
||||
{ key: 'settings', label: 'Einstellungen oeffnen', icon: <Settings size={16} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="surface-card-strong">
|
||||
<Card className="bg-surface-secondary">
|
||||
<CardContent className="flex flex-col gap-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)} />
|
||||
<AppAvatar className="size-20 shrink-0" size="lg" src={guildIconUrl(selectedGuild)} name={guildInfo?.name || selectedGuild?.name} />
|
||||
<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 color="success" size="sm" variant="soft">
|
||||
<Bot size={12} /> Online
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="mt-1 text-small section-subtle">ID: {guildInfo?.id || selectedGuild?.id}</div>
|
||||
<div className="mt-1 text-sm text-muted">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} size="sm" variant="flat" className="accent-chip">
|
||||
<Chip key={key} size="sm" variant="soft" color="accent">
|
||||
{key.replace('Enabled', '').replace(/([A-Z])/g, ' $1').trim()}
|
||||
</Chip>
|
||||
))}
|
||||
@@ -44,8 +46,8 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Chip radius="sm" size="sm" variant="flat" startContent={<Gauge size={12} />} className="accent-chip">
|
||||
Ping: {guildInfo?.ping || '-'}ms
|
||||
<Chip size="sm" variant="soft" color="accent">
|
||||
<Gauge size={12} /> Ping: {guildInfo?.ping || '-'}ms
|
||||
</Chip>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -65,40 +67,44 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Activity Bereich</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Live Statistiken</p>
|
||||
<p className="mt-0.5 text-xs text-muted">Live Statistiken</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<StatCard icon={<MessageSquare size={16} />} label="Nachrichten" value={activity?.messages24h ?? 0} />
|
||||
<StatCard icon={<Command size={16} />} label="Commands" value={activity?.commands24h ?? 0} color="success" />
|
||||
<StatCard icon={<Shield size={16} />} label="Automod" value={activity?.automod24h ?? 0} color="warning" />
|
||||
<StatCard icon={<Users size={16} />} label="Neue User" value={activity?.newUsers24h ?? 0} />
|
||||
<CardContent>
|
||||
<BarComparisonChart
|
||||
items={[
|
||||
{ label: 'Nachrichten', value: activity?.messages24h ?? 0 },
|
||||
{ label: 'Commands', value: activity?.commands24h ?? 0 },
|
||||
{ label: 'Automod', value: activity?.automod24h ?? 0 },
|
||||
{ label: 'Neue User', value: activity?.newUsers24h ?? 0 },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Guild Logs</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Letzte Ereignisse</p>
|
||||
<p className="mt-0.5 text-xs text-muted">Letzte Ereignisse</p>
|
||||
</div>
|
||||
<Button size="sm" variant="light" className="ghost-button" endContent={<ChevronRight size={14} />} onPress={() => setSection('settings')}>
|
||||
Alle
|
||||
<Button size="sm" variant="ghost" onPress={() => setSection('settings')}>
|
||||
Alle <ChevronRight size={14} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollShadow className="max-h-[320px] space-y-2 pr-1" hideScrollBar>
|
||||
{logs.length ? logs.slice(0, 15).map((log, i) => (
|
||||
<Card key={`${log.timestamp}-${i}`} className="surface-card-muted">
|
||||
<Card key={`${log.timestamp}-${i}`} className="bg-surface-tertiary">
|
||||
<CardContent className="flex items-start gap-3 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-orange-500/10 text-orange-300'
|
||||
log.level === 'error' ? 'bg-danger-soft text-danger' :
|
||||
log.level === 'warn' ? 'bg-warning-soft text-warning' :
|
||||
'bg-accent-soft text-accent-soft-foreground'
|
||||
}`}>
|
||||
{log.level === 'error' ? <Shield size={12} /> :
|
||||
log.level === 'warn' ? <Bell size={12} /> :
|
||||
@@ -109,20 +115,20 @@ export function Dashboard() {
|
||||
<Chip
|
||||
color={log.level === 'error' ? 'danger' : log.level === 'warn' ? 'warning' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
variant="soft"
|
||||
>
|
||||
{(log.level || 'info').toUpperCase()}
|
||||
</Chip>
|
||||
<span className="text-tiny text-default-400">{formatDate(log.timestamp)}</span>
|
||||
<span className="text-xs text-muted">{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 className="mt-1 text-sm text-foreground/80">
|
||||
{log.category ? <span className="text-muted">[{log.category}] </span> : ''}{log.message || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center text-sm text-muted">
|
||||
<Activity size={20} />
|
||||
Keine Logs
|
||||
</div>
|
||||
@@ -131,22 +137,20 @@ export function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-bold">Quick Actions</h2>
|
||||
<p className="mt-0.5 text-tiny text-default-400">Schnellzugriff</p>
|
||||
<p className="mt-0.5 text-xs text-muted">Schnellzugriff</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
color="default"
|
||||
startContent={action.icon}
|
||||
variant="light"
|
||||
className={`justify-start font-medium ${action.color === 'default' ? 'ghost-button' : 'accent-button'}`}
|
||||
variant="tertiary"
|
||||
className="justify-start font-medium"
|
||||
onPress={() => setSection(action.key as any)}
|
||||
>
|
||||
{action.label}
|
||||
{action.icon} {action.label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -157,13 +161,20 @@ export function Dashboard() {
|
||||
{(['tickets', 'supportlogin', 'automod', 'welcome', 'birthday', 'reactionroles'] as const).map((key) => {
|
||||
const item = navItemMap[key];
|
||||
return (
|
||||
<Card key={key} isPressable onPress={() => setSection(key)} className="surface-card">
|
||||
<Card
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSection(key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSection(key); }}
|
||||
className="cursor-pointer border border-transparent transition-colors hover:border-accent"
|
||||
>
|
||||
<CardContent className="flex flex-col items-start gap-2">
|
||||
<Chip size="sm" variant="flat" startContent={item.icon} className="accent-chip">
|
||||
{item.label}
|
||||
<Chip size="sm" variant="soft" color="accent">
|
||||
{item.icon} {item.label}
|
||||
</Chip>
|
||||
<div className="font-semibold text-sm">{item.label}</div>
|
||||
<div className="text-tiny text-default-400">Modul verwalten</div>
|
||||
<div className="text-xs text-muted">Modul verwalten</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function DynamicVoice() {
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
@@ -30,7 +30,7 @@ export function DynamicVoice() {
|
||||
<TextField>
|
||||
<Label>Kategorie ID</Label>
|
||||
<Input
|
||||
placeholder="Kategorie f<EFBFBD>r neue Channels"
|
||||
placeholder="Kategorie für neue Channels"
|
||||
value={settings.dynamicVoiceConfig?.categoryId || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: e.target.value } }))}
|
||||
/>
|
||||
@@ -47,25 +47,25 @@ export function DynamicVoice() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}>
|
||||
Speichern
|
||||
<Button variant="primary" onPress={() => saveSettingsPayload({ dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
|
||||
<Mic size={16} className="text-accent" />
|
||||
<span className="text-muted">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 className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
|
||||
<Users size={16} className="text-success" />
|
||||
<span className="text-muted">Channel-Owner können Limits und Berechtigungen verwalten</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -14,26 +14,26 @@ export function Events() {
|
||||
<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">
|
||||
<Card key={event.id}>
|
||||
<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>
|
||||
<CalendarDays size={16} className="text-accent shrink-0" />
|
||||
<span className="font-semibold text-sm truncate">{event.title}</span>
|
||||
</div>
|
||||
<Button color="danger" size="sm" variant="flat" startContent={<Trash2 size={14} />} onPress={() => deleteEvent(event.id)}>
|
||||
L<EFBFBD>schen
|
||||
<Button size="sm" variant="danger-soft" onPress={() => deleteEvent(event.id)}>
|
||||
<Trash2 size={14} /> Lö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">
|
||||
<p className="text-sm text-muted">{event.description || 'Keine Beschreibung'}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<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">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<CalendarDays size={24} />
|
||||
Keine Events
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@ export function Events() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neues Event</h3>
|
||||
</CardHeader>
|
||||
@@ -67,7 +67,7 @@ export function Events() {
|
||||
<TextField>
|
||||
<Label>Channel ID</Label>
|
||||
<Input
|
||||
placeholder="Channel f<EFBFBD>r Erinnerungen"
|
||||
placeholder="Channel für Erinnerungen"
|
||||
value={eventDraft.channelId}
|
||||
onChange={(e) => setEventDraft((s) => ({ ...s, channelId: e.target.value }))}
|
||||
/>
|
||||
@@ -83,8 +83,8 @@ export function Events() {
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<Button color="primary" startContent={<Plus size={16} />} onPress={saveEvent}>
|
||||
Event speichern
|
||||
<Button variant="primary" onPress={saveEvent}>
|
||||
<Plus size={16} /> Event speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, CardContent, Avatar, Button } from '@heroui/react';
|
||||
import { Card, CardContent } from '@heroui/react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { guildIconUrl } from '../utils/formatters';
|
||||
import { AppAvatar } from '../components/shared/AppAvatar';
|
||||
|
||||
export function GuildSelect() {
|
||||
const { guilds, setCurrentGuildId } = useApp();
|
||||
@@ -8,26 +9,28 @@ export function GuildSelect() {
|
||||
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">
|
||||
<div className="bg-accent text-accent-foreground mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl text-2xl font-black">
|
||||
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>
|
||||
<p className="mt-2 text-muted">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)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer border border-transparent transition-colors hover:border-accent"
|
||||
onClick={() => setCurrentGuildId(guild.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') 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" />
|
||||
<AppAvatar src={guildIconUrl(guild)} name={guild.name} 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 className="mt-0.5 text-sm text-muted">ID: {guild.id}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -15,20 +15,20 @@ export function ModulesPage() {
|
||||
{activeModules.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<CheckCircle size={16} className="text-success-400" />
|
||||
<CheckCircle size={16} className="text-success" />
|
||||
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="surface-card-strong">
|
||||
<Card key={module.key} className="bg-surface-secondary">
|
||||
<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 className="text-sm text-muted truncate">{module.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch isSelected={module.enabled} color="warning" onChange={(v) => toggleModule(module.key, v)} />
|
||||
<Switch isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -39,20 +39,20 @@ export function ModulesPage() {
|
||||
{inactiveModules.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<XCircle size={16} className="text-default-400" />
|
||||
<XCircle size={16} className="text-muted" />
|
||||
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="surface-card">
|
||||
<Card key={module.key}>
|
||||
<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 className="text-sm text-muted truncate">{module.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch isSelected={module.enabled} color="primary" onChange={(v) => toggleModule(module.key, v)} />
|
||||
<Switch isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -61,9 +61,9 @@ export function ModulesPage() {
|
||||
)}
|
||||
|
||||
{modules.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<Puzzle size={24} />
|
||||
Keine Module verf<EFBFBD>gbar
|
||||
Keine Module verfügbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,46 +11,46 @@ export function MusicPage() {
|
||||
<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={<Music size={18} />} label="Aktive Guilds" value={musicStatus.activeGuilds} color="accent" />
|
||||
<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">
|
||||
<Card key={session.guildId}>
|
||||
<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>
|
||||
<Music size={16} className="text-accent" />
|
||||
<span className="font-semibold text-sm">{session.guildId}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Chip size="sm" variant="flat" startContent={<Repeat size={12} />}>
|
||||
{session.loop}
|
||||
<Chip size="sm" variant="soft">
|
||||
<Repeat size={12} /> {session.loop}
|
||||
</Chip>
|
||||
<Chip size="sm" variant="flat" startContent={<List size={12} />}>
|
||||
{session.queueLength} in Queue
|
||||
<Chip size="sm" variant="soft">
|
||||
<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">
|
||||
<div className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
|
||||
<span className="text-muted">Jetzt läuft: </span>
|
||||
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent 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">
|
||||
<div className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm text-muted">
|
||||
Keine aktive Wiedergabe
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<Music size={24} />
|
||||
Keine aktiven Musik-Sessions
|
||||
</div>
|
||||
|
||||
@@ -13,20 +13,20 @@ export function ReactionRoles() {
|
||||
<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">
|
||||
<Card key={set.id || i}>
|
||||
<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">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-accent-soft text-accent-soft-foreground">
|
||||
<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 className="font-semibold text-sm truncate">{set.title || 'Reaction Role'}</div>
|
||||
<div className="text-xs text-muted truncate">Channel: {set.channelId || '-'}</div>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat">{(set.entries?.length || 0)} Eintr<EFBFBD>ge</Chip>
|
||||
<Chip size="sm" variant="soft">{(set.entries?.length || 0)} Einträge</Chip>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<Tag size={24} />
|
||||
Keine Sets
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ export function ReactionRoles() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neues Set</h3>
|
||||
</CardHeader>
|
||||
@@ -51,27 +51,27 @@ export function ReactionRoles() {
|
||||
<TextField>
|
||||
<Label>Channel ID</Label>
|
||||
<Input
|
||||
placeholder="Channel f<EFBFBD>r die Nachricht"
|
||||
placeholder="Channel für die Nachricht"
|
||||
value={reactionDraft.channelId}
|
||||
onChange={(e) => setReactionDraft((s) => ({ ...s, channelId: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<div>
|
||||
<label className="block text-small font-medium mb-1">Eintr<EFBFBD>ge</label>
|
||||
<TextField>
|
||||
<Label>Einträge</Label>
|
||||
<TextArea
|
||||
placeholder="Emoji | Role ID | Label :emoji: | 123456789 | Rolle 1 :wave: | 987654321 | Rolle 2"
|
||||
minRows={6}
|
||||
rows={6}
|
||||
value={reactionDraft.entries}
|
||||
onChange={(e) => setReactionDraft((s) => ({ ...s, entries: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-tiny text-default-400">
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Pro Zeile: Emoji | Role ID | Label (optional) | Beschreibung (optional)
|
||||
</p>
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveReactionRole}>
|
||||
Reaction Role speichern
|
||||
<Button variant="primary" onPress={saveReactionRole}>
|
||||
<Save size={16} /> Reaction Role speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -12,10 +12,14 @@ export function Register() {
|
||||
} = useApp();
|
||||
|
||||
return (
|
||||
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Antr<EFBFBD>ge verwalten.">
|
||||
<Tabs aria-label="Register Tabs" color="primary" selectedKey={registerTab} variant="bordered" onSelectionChange={(key) => setRegisterTab(String(key))}>
|
||||
<Tab key="forms">Formulare</Tab>
|
||||
<Tab key="apps">Anträge</Tab>
|
||||
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Anträge verwalten.">
|
||||
<Tabs selectedKey={registerTab} variant="primary" onSelectionChange={(key) => setRegisterTab(String(key))}>
|
||||
<Tabs.ListContainer>
|
||||
<Tabs.List aria-label="Register Tabs">
|
||||
<Tab id="forms">Formulare</Tab>
|
||||
<Tab id="apps">Anträge</Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
|
||||
{registerTab === 'forms' && (
|
||||
@@ -24,15 +28,15 @@ export function Register() {
|
||||
<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">
|
||||
<Card key={f.id}>
|
||||
<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>
|
||||
<FileText size={16} className="text-accent shrink-0" />
|
||||
<span className="font-semibold text-sm truncate">{f.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button isIconOnly size="sm" variant="light" onPress={() => {
|
||||
<Button isIconOnly size="sm" variant="ghost" onPress={() => {
|
||||
setFormDraft({
|
||||
name: f.name, description: f.description || '',
|
||||
reviewChannelId: f.reviewChannelId || '',
|
||||
@@ -45,25 +49,25 @@ export function Register() {
|
||||
}}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteForm(f.id)}>
|
||||
<Button isIconOnly size="sm" variant="danger-soft" 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'}>
|
||||
<p className="text-sm text-muted">{f.description || 'Keine Beschreibung'}</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Chip size="sm" variant="soft" 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
|
||||
<span className="text-muted">{f.fields?.length || 0} Felder</span>
|
||||
<Button size="sm" variant="tertiary" onPress={() => sendFormPanel(f.id)}>
|
||||
<Send size={12} /> Panel senden
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<ClipboardList size={24} />
|
||||
Keine Formulare
|
||||
</div>
|
||||
@@ -71,7 +75,7 @@ export function Register() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">{editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}</h3>
|
||||
</CardHeader>
|
||||
@@ -94,15 +98,15 @@ export function Register() {
|
||||
</TextField>
|
||||
<TextField>
|
||||
<Label>Felder (label|type|required|options)</Label>
|
||||
<TextArea minRows={6} value={formDraft.fields} onChange={(e) => setFormDraft((s) => ({ ...s, fields: e.target.value }))} />
|
||||
<TextArea rows={6} value={formDraft.fields} onChange={(e) => setFormDraft((s) => ({ ...s, fields: e.target.value }))} />
|
||||
</TextField>
|
||||
<p className="text-tiny text-default-400">
|
||||
<p className="text-xs text-muted">
|
||||
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>
|
||||
<Button variant="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
|
||||
{editingFormId && (
|
||||
<Button variant="flat" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
|
||||
<Button variant="tertiary" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
@@ -114,23 +118,23 @@ export function Register() {
|
||||
|
||||
{registerTab === 'apps' && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-3 text-base font-semibold">Eingegangene Antr<EFBFBD>ge ({registerApps.length})</h3>
|
||||
<h3 className="mb-3 text-base font-semibold">Eingegangene Anträ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">
|
||||
<Card key={app.id}>
|
||||
<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'}>
|
||||
<Chip size="sm" variant="soft" 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>
|
||||
<div className="text-xs text-muted">{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>
|
||||
<div key={i} className="text-sm">
|
||||
<span className="text-muted">{a.label || 'Frage'}: </span>
|
||||
{a.value}
|
||||
</div>
|
||||
))}
|
||||
@@ -139,9 +143,9 @@ export function Register() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
||||
<ClipboardList size={24} />
|
||||
Keine Antr<EFBFBD>ge
|
||||
Keine Anträge
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ServerStats() {
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
@@ -23,7 +23,7 @@ export function ServerStats() {
|
||||
<TextField>
|
||||
<Label>Kategorie-Name</Label>
|
||||
<Input
|
||||
placeholder="?? Server Stats"
|
||||
placeholder="Server Stats"
|
||||
value={statsDraft?.categoryName || ''}
|
||||
onChange={(e) => setStatsDraft((s) => ({ ...(s || {}), categoryName: e.target.value }))}
|
||||
/>
|
||||
@@ -38,30 +38,30 @@ export function ServerStats() {
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveServerStats}>
|
||||
Server Stats speichern
|
||||
<Button variant="primary" onPress={saveServerStats}>
|
||||
<Save size={16} /> Server Stats speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={14} className="text-primary-400" />
|
||||
<Activity size={14} className="text-accent" />
|
||||
<span className="font-medium">{item.label || item.key}</span>
|
||||
<Chip size="sm" variant="flat">{item.type || '-'}</Chip>
|
||||
<Chip size="sm" variant="soft">{item.type || '-'}</Chip>
|
||||
</div>
|
||||
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatsItem(i)}>
|
||||
<Button isIconOnly size="sm" variant="danger-soft" 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">
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<BarChart3 size={20} />
|
||||
Keine Items
|
||||
</div>
|
||||
@@ -70,11 +70,11 @@ export function ServerStats() {
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-small font-semibold mb-2">Item hinzuf<EFBFBD>gen</h4>
|
||||
<h4 className="text-sm font-semibold mb-2">Item hinzufügen</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Label" value={statsItemDraft.label} onChange={(e) => setStatsItemDraft((s) => ({ ...s, label: e.target.value }))} />
|
||||
<select
|
||||
className="w-full rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm outline-none"
|
||||
className="w-full rounded-xl px-3 py-2 text-sm"
|
||||
value={statsItemDraft.type}
|
||||
onChange={(e) => setStatsItemDraft((s) => ({ ...s, type: e.target.value }))}
|
||||
>
|
||||
@@ -85,8 +85,8 @@ export function ServerStats() {
|
||||
<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 size="sm" variant="primary" onPress={addStatsItem}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function SettingsPage() {
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Allgemein</h3>
|
||||
</CardHeader>
|
||||
@@ -43,13 +43,13 @@ export function SettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
Speichern
|
||||
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Logging Kategorien</h3>
|
||||
</CardHeader>
|
||||
@@ -76,8 +76,8 @@ export function SettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
Speichern
|
||||
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Statuspage() {
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Konfiguration</h3>
|
||||
</CardHeader>
|
||||
@@ -24,7 +24,7 @@ export function Statuspage() {
|
||||
<TextField>
|
||||
<Label>Channel ID</Label>
|
||||
<Input
|
||||
placeholder="Channel f<EFBFBD>r Status-Updates"
|
||||
placeholder="Channel für Status-Updates"
|
||||
value={statusDraft?.channelId || ''}
|
||||
onChange={(e) => setStatusDraft((s) => ({ ...(s || {}), channelId: e.target.value }))}
|
||||
/>
|
||||
@@ -39,39 +39,39 @@ export function Statuspage() {
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveStatuspage}>
|
||||
Statuspage speichern
|
||||
<Button variant="primary" onPress={saveStatuspage}>
|
||||
<Save size={16} /> Statuspage speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 key={service.id} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<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>
|
||||
<span className="ml-2 text-muted text-xs truncate">{service.url || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" variant="flat" color={
|
||||
<Chip size="sm" variant="soft" 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)}>
|
||||
<Button isIconOnly size="sm" variant="danger-soft" 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">
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<ActivityIcon size={20} />
|
||||
Keine Services
|
||||
</div>
|
||||
@@ -80,12 +80,12 @@ export function Statuspage() {
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-small font-semibold mb-2">Service hinzuf<EFBFBD>gen</h4>
|
||||
<h4 className="text-sm font-semibold mb-2">Service hinzufügen</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Name" value={statusServiceDraft.name} onChange={(e) => setStatusServiceDraft((s) => ({ ...s, name: e.target.value }))} />
|
||||
<Input placeholder="URL (optional)" value={statusServiceDraft.url} onChange={(e) => setStatusServiceDraft((s) => ({ ...s, url: e.target.value }))} />
|
||||
<Button size="sm" color="primary" startContent={<Plus size={14} />} onPress={addStatusService}>
|
||||
Hinzuf<EFBFBD>gen
|
||||
<Button size="sm" variant="primary" onPress={addStatusService}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Avatar, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
|
||||
import { LogIn, UserRound, Save, Send } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { AppAvatar } from '../components/shared/AppAvatar';
|
||||
|
||||
export function SupportLogin() {
|
||||
const { supportLogin, setSupportLogin, saveSupportLogin } = useApp();
|
||||
@@ -9,7 +10,7 @@ export function SupportLogin() {
|
||||
return (
|
||||
<SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren">
|
||||
<div className="grid gap-5 xl:grid-cols-[1fr_400px]">
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Panel-Konfiguration</CardTitle>
|
||||
@@ -27,8 +28,6 @@ export function SupportLogin() {
|
||||
<TextField>
|
||||
<Label>Panel Channel ID</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
placeholder="Channel ID eingeben"
|
||||
value={supportLogin?.config?.panelChannelId || ''}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: e.target.value } } : s)}
|
||||
@@ -38,8 +37,6 @@ export function SupportLogin() {
|
||||
<TextField>
|
||||
<Label>Titel</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
value={supportLogin?.config?.title || 'Support Login'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: e.target.value } } : s)}
|
||||
/>
|
||||
@@ -48,7 +45,6 @@ export function SupportLogin() {
|
||||
<TextField>
|
||||
<Label>Beschreibung</Label>
|
||||
<TextArea
|
||||
variant="bordered"
|
||||
value={supportLogin?.config?.description || ''}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: e.target.value } } : s)}
|
||||
/>
|
||||
@@ -57,8 +53,6 @@ export function SupportLogin() {
|
||||
<TextField>
|
||||
<Label>Login Button Label</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: e.target.value } } : s)}
|
||||
/>
|
||||
@@ -67,8 +61,6 @@ export function SupportLogin() {
|
||||
<TextField>
|
||||
<Label>Logout Button Label</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: e.target.value } } : s)}
|
||||
/>
|
||||
@@ -77,18 +69,18 @@ export function SupportLogin() {
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="light" className="accent-button" startContent={<Save size={16} />} onPress={saveSupportLogin}>
|
||||
Speichern & Panel senden
|
||||
<Button variant="primary" onPress={saveSupportLogin}>
|
||||
<Save size={16} /> Speichern & Panel senden
|
||||
</Button>
|
||||
<Button variant="light" className="ghost-button" startContent={<Send size={16} />}>
|
||||
Panel manuell senden
|
||||
<Button variant="ghost">
|
||||
<Send size={16} /> Panel manuell senden
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Live Vorschau</CardTitle>
|
||||
@@ -96,10 +88,10 @@ export function SupportLogin() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className="surface-card-muted">
|
||||
<Card className="bg-surface-tertiary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogIn size={16} className="accent-text" />
|
||||
<LogIn size={16} className="text-accent" />
|
||||
<div>
|
||||
<CardTitle>{supportLogin?.config?.title || 'Support Login'}</CardTitle>
|
||||
<CardDescription>{supportLogin?.config?.description || 'Melde dich als Support an/ab.'}</CardDescription>
|
||||
@@ -107,14 +99,14 @@ export function SupportLogin() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<Button size="sm" variant="light" className="accent-button">{supportLogin?.config?.loginLabel || 'Login'}</Button>
|
||||
<Button size="sm" variant="light" className="ghost-button">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
|
||||
<Button size="sm" variant="primary">{supportLogin?.config?.loginLabel || 'Login'}</Button>
|
||||
<Button size="sm" variant="ghost">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Aktive Supporter</CardTitle>
|
||||
@@ -123,20 +115,20 @@ export function SupportLogin() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
|
||||
<Card key={i} className="surface-card-muted">
|
||||
<Card key={i} className="bg-surface-tertiary">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<Avatar name={s.username?.[0]} size="sm" className="size-6" />
|
||||
<AppAvatar name={s.username} 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>
|
||||
<Chip size="sm" variant="soft" color="success" className="ml-auto">Online</Chip>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<UserRound size={20} />
|
||||
Keine aktiven Supporter
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-tiny text-default-400">
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -21,19 +21,23 @@ export function 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">Übersicht</Tab>
|
||||
<Tab key="pipeline">Pipeline</Tab>
|
||||
<Tab key="sla">SLA</Tab>
|
||||
<Tab key="automations">Automationen</Tab>
|
||||
<Tab key="kb">Knowledge Base</Tab>
|
||||
<Tabs selectedKey={ticketTab} variant="primary" onSelectionChange={(key) => setTicketTab(String(key))}>
|
||||
<Tabs.ListContainer>
|
||||
<Tabs.List aria-label="Ticket Tabs">
|
||||
<Tab id="overview">Übersicht</Tab>
|
||||
<Tab id="pipeline">Pipeline</Tab>
|
||||
<Tab id="sla">SLA</Tab>
|
||||
<Tab id="automations">Automationen</Tab>
|
||||
<Tab id="kb">Knowledge Base</Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</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={<Clock size={18} />} label="In Bearbeitung" value={overview?.tickets?.inProgress ?? 0} color="accent" />
|
||||
<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>
|
||||
@@ -43,36 +47,36 @@ export function Tickets() {
|
||||
<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">
|
||||
<Card key={t.id}>
|
||||
<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'
|
||||
t.status === 'open' ? 'bg-warning' :
|
||||
t.status === 'in-progress' ? 'bg-accent' :
|
||||
t.status === 'waiting' ? 'bg-default' : 'bg-success'
|
||||
}`} />
|
||||
<span className="font-semibold text-small truncate">{t.topic || 'Ticket'}</span>
|
||||
<span className="font-semibold text-sm truncate">{t.topic || 'Ticket'}</span>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
|
||||
<Chip size="sm" variant="soft" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'accent'}>
|
||||
{t.status || 'open'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-tiny text-default-400">
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<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>
|
||||
<Chip size="sm" variant="soft" 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"
|
||||
className="w-40 rounded-xl text-sm"
|
||||
value=""
|
||||
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
|
||||
>
|
||||
@@ -82,8 +86,8 @@ export function Tickets() {
|
||||
<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>
|
||||
<Button size="sm" variant="danger" onPress={() => closeTicket(t.id)}>Schließen</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -95,12 +99,12 @@ export function Tickets() {
|
||||
<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 key={t.id} className="bg-surface flex items-center justify-between rounded-xl 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 className="text-sm font-medium truncate">{t.topic || t.id}</div>
|
||||
<div className="text-xs text-muted">{t.category || '-'} · {formatDate(t.createdAt)}</div>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
|
||||
<Chip size="sm" variant="soft" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'accent'}>
|
||||
{t.status}
|
||||
</Chip>
|
||||
</div>
|
||||
@@ -110,33 +114,33 @@ export function Tickets() {
|
||||
</div>
|
||||
|
||||
{ticketDetail && (
|
||||
<Card className="border border-default-100">
|
||||
<Card>
|
||||
<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>
|
||||
<Button size="sm" variant="ghost" 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 className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div className="rounded-lg bg-default-soft px-3 py-2">
|
||||
<span className="text-muted">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 className="rounded-lg bg-default-soft px-3 py-2">
|
||||
<span className="text-muted">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 className="rounded-lg bg-default-soft px-3 py-2">
|
||||
<span className="text-muted">Kategorie:</span> {ticketDetail.category || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-2 text-small font-semibold">Nachrichten ({ticketMessages.length})</h4>
|
||||
<h4 className="mb-2 text-sm 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>
|
||||
<div key={i} className="rounded-lg bg-default-soft px-3 py-2 text-sm">
|
||||
<span className="text-xs text-muted">{msg.author?.tag || msg.authorId}: </span>
|
||||
{msg.content || '(Embed)'}
|
||||
</div>
|
||||
)) : <p className="text-tiny text-default-400">Keine Nachrichten</p>}
|
||||
)) : <p className="text-xs text-muted">Keine Nachrichten</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -149,31 +153,31 @@ export function Tickets() {
|
||||
<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: 'in_bearbeitung', label: 'In Bearbeitung', color: 'accent' 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">
|
||||
<Card key={key}>
|
||||
<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`} />
|
||||
<div className={`size-2.5 rounded-full bg-${color}`} />
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<Chip size="sm" variant="flat" color={color}>{(pipeline[key] || []).length}</Chip>
|
||||
<Chip size="sm" variant="soft" 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 key={t.id} className="bg-surface-tertiary rounded-xl p-3 text-sm">
|
||||
<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={() => {
|
||||
<div className="mt-1 text-xs text-muted">{formatDate(t.createdAt)}</div>
|
||||
<Button size="sm" variant="tertiary" className="mt-2 h-6 min-w-0 px-2 text-xs" 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>}
|
||||
)) : <p className="text-xs text-muted text-center py-4">Keine Tickets</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -182,41 +186,41 @@ export function Tickets() {
|
||||
|
||||
{ticketTab === 'sla' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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 key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRound size={14} className="text-default-400" />
|
||||
<UserRound size={14} className="text-muted" />
|
||||
<span className="font-medium">{row.supporterId || '-'}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-tiny text-default-500">
|
||||
<div className="flex gap-3 text-xs text-muted">
|
||||
<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>}
|
||||
)) : <p className="text-xs text-muted text-center py-4">Keine Daten</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<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">
|
||||
<div key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-2 text-xs">
|
||||
<span className="font-medium">{row.date || '-'}</span>
|
||||
<div className="flex gap-2 text-default-500">
|
||||
<div className="flex gap-2 text-muted">
|
||||
<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>}
|
||||
)) : <p className="text-xs text-muted text-center py-4">Keine Daten</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -228,20 +232,20 @@ export function Tickets() {
|
||||
<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">
|
||||
<Card key={rule.id}>
|
||||
<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'}>
|
||||
<div className="font-semibold text-sm">{rule.name || 'Automation'}</div>
|
||||
<Chip size="sm" variant="soft" 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 size="sm" variant="tertiary" onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
|
||||
<Pencil size={14} /> Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteAutomation(rule.id)}>
|
||||
Löschen
|
||||
<Button size="sm" variant="danger-soft" onPress={() => deleteAutomation(rule.id)}>
|
||||
<Trash2 size={14} /> Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -251,7 +255,7 @@ export function Tickets() {
|
||||
</div>
|
||||
<div>
|
||||
{automationEditDraft ? (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Automation bearbeiten</h3>
|
||||
</CardHeader>
|
||||
@@ -269,13 +273,13 @@ export function Tickets() {
|
||||
<TextArea value={automationEditDraft.actionValue} onChange={(e) => setAutomationEditDraft((s) => ({ ...s, actionValue: e.target.value }))} />
|
||||
</TextField>
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="flat" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
|
||||
<Button variant="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="tertiary" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neue Automation</h3>
|
||||
</CardHeader>
|
||||
@@ -292,7 +296,7 @@ export function Tickets() {
|
||||
<Label>Aktion / Nachricht</Label>
|
||||
<TextArea value={automationDraft.actionValue} onChange={(e) => setAutomationDraft((s) => ({ ...s, actionValue: e.target.value }))} />
|
||||
</TextField>
|
||||
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
|
||||
<Button variant="primary" onPress={saveAutomation}>Automation speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -306,19 +310,19 @@ export function Tickets() {
|
||||
<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">
|
||||
<Card key={article.id}>
|
||||
<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 className="font-semibold text-sm truncate">{article.title || 'Artikel'}</div>
|
||||
<Chip size="sm" variant="soft">{(article.keywords?.length || 0)} Keywords</Chip>
|
||||
</div>
|
||||
<p className="text-small text-default-400 line-clamp-2">{article.content || '-'}</p>
|
||||
<p className="text-sm text-muted 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 size="sm" variant="tertiary" onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
|
||||
<Pencil size={14} /> Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteKbArticle(article.id)}>
|
||||
Löschen
|
||||
<Button size="sm" variant="danger-soft" onPress={() => deleteKbArticle(article.id)}>
|
||||
<Trash2 size={14} /> Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -328,7 +332,7 @@ export function Tickets() {
|
||||
</div>
|
||||
<div>
|
||||
{kbEditDraft ? (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Artikel bearbeiten</h3>
|
||||
</CardHeader>
|
||||
@@ -343,16 +347,16 @@ export function Tickets() {
|
||||
</TextField>
|
||||
<TextField>
|
||||
<Label>Inhalt</Label>
|
||||
<TextArea minRows={5} value={kbEditDraft.content} onChange={(e) => setKbEditDraft((s) => ({ ...s, content: e.target.value }))} />
|
||||
<TextArea rows={5} value={kbEditDraft.content} onChange={(e) => setKbEditDraft((s) => ({ ...s, content: e.target.value }))} />
|
||||
</TextField>
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="flat" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
|
||||
<Button variant="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
|
||||
<Button variant="tertiary" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border border-default-100 bg-default-50/20">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neuer KB-Artikel</h3>
|
||||
</CardHeader>
|
||||
@@ -367,9 +371,9 @@ export function Tickets() {
|
||||
</TextField>
|
||||
<TextField>
|
||||
<Label>Inhalt</Label>
|
||||
<TextArea minRows={5} value={kbDraft.content} onChange={(e) => setKbDraft((s) => ({ ...s, content: e.target.value }))} />
|
||||
<TextArea rows={5} value={kbDraft.content} onChange={(e) => setKbDraft((s) => ({ ...s, content: e.target.value }))} />
|
||||
</TextField>
|
||||
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
|
||||
<Button variant="primary" onPress={saveKbArticle}>Artikel speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function Welcome() {
|
||||
return (
|
||||
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Welcome konfigurieren</CardTitle>
|
||||
@@ -24,8 +24,6 @@ export function Welcome() {
|
||||
<TextField>
|
||||
<Label>Channel ID</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
placeholder="Channel ID fuer Willkommensnachrichten"
|
||||
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: e.target.value } }))}
|
||||
@@ -35,8 +33,6 @@ export function Welcome() {
|
||||
<TextField>
|
||||
<Label>Titel</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
placeholder="Willkommen {user}!"
|
||||
value={settings.welcomeConfig?.embedTitle || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedTitle: e.target.value } }))}
|
||||
@@ -46,7 +42,6 @@ export function Welcome() {
|
||||
<TextField>
|
||||
<Label>Beschreibung</Label>
|
||||
<TextArea
|
||||
variant="bordered"
|
||||
placeholder="Beschreibung des Embeds"
|
||||
value={settings.welcomeConfig?.embedDescription || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedDescription: e.target.value } }))}
|
||||
@@ -56,8 +51,6 @@ export function Welcome() {
|
||||
<TextField>
|
||||
<Label>Footer</Label>
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
placeholder={new Date().getFullYear().toString()}
|
||||
value={settings.welcomeConfig?.embedFooter || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedFooter: e.target.value } }))}
|
||||
@@ -66,13 +59,13 @@ export function Welcome() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button size="lg" variant="light" className="accent-button" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
|
||||
Speichern
|
||||
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="surface-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Live Vorschau</CardTitle>
|
||||
@@ -80,22 +73,22 @@ export function Welcome() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Card className="surface-card-muted">
|
||||
<Card className="bg-surface-tertiary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="accent-text" />
|
||||
<Sparkles size={16} className="text-accent" />
|
||||
<CardTitle>{settings.welcomeConfig?.embedTitle || 'Willkommen!'}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-default-700 dark:text-default-300">{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
|
||||
<p className="text-foreground/80">{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
|
||||
{settings.welcomeConfig?.embedFooter && (
|
||||
<p className="text-small section-subtle">{settings.welcomeConfig.embedFooter}</p>
|
||||
<p className="text-sm text-muted">{settings.welcomeConfig.embedFooter}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-small section-subtle">
|
||||
<p className="text-sm text-muted">
|
||||
Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen.
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user