improve dashboard usability and fix backend TS build errors
Some checks failed
Deploy Discord Bot / deploy (push) Failing after -1m3s
SonarQube / sonar (push) Successful in 1m17s

Frontend: fix HeroUI Switch usage across the whole app (Content/Control/
Thumb composition was missing, so no toggle ever rendered visibly),
replace raw channel/role ID text fields with proper name-based dropdowns
(ChannelSelect/RoleSelect) backed by the existing /guild/resources
endpoint, redesign Automod's filters as inline toggle rows, turn the
Ticket Pipeline tab into a drag-and-drop Kanban board, narrow the
sidebar further and rework its collapse toggle and profile footer, and
add a Discord-style message preview for Welcome/Support Login.

Backend: fix the ~50 TypeScript build errors blocking `npm run build`
(SlashCommandBuilder typing, discord.js v14 channel-union guards,
Prisma JSON null handling, logger.warn signature, and related type
narrowing) so the project compiles cleanly again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Pepe44DEV
2026-07-02 13:55:17 +02:00
parent 0c47dce508
commit 3c31832a0d
35 changed files with 691 additions and 310 deletions

View File

@@ -3,6 +3,15 @@
@custom-variant dark (&:is(.dark *));
:root {
/* HeroUI ships fields with a transparent border and 0 border-width by default,
so an Input on a plain Card is invisible (identical bg, no outline). Give
every field a real, visible box across the whole app. */
--field-border-width: 1px;
--field-border: var(--border-secondary);
--field-background: var(--surface-tertiary);
}
html, body, #root {
min-height: 100%;
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import {
Button, Card, CardContent, ScrollShadow, Tooltip,
Button, ScrollShadow, Tooltip,
Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem
} from '@heroui/react';
import {
@@ -64,18 +64,23 @@ export function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
return (
<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="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 text-muted">Dashboard</div>
</div>
)}
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">P</div>
{!collapsed && (
<div className="min-w-0">
<div className="text-sm font-bold leading-tight">Papo</div>
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
</div>
)}
</div>
<Button isIconOnly size="sm" variant="ghost" className="shrink-0" onPress={() => setCollapsed((c) => !c)}>
{collapsed ? <PanelLeft size={15} /> : <PanelLeftClose size={15} />}
</Button>
</div>
<div className="px-3 pb-2">
<div className="px-2 pb-2">
<Select
aria-label="Guild auswaehlen"
selectedKey={currentGuildId}
@@ -83,8 +88,8 @@ export function Sidebar() {
if (typeof key === 'string') setCurrentGuildId(key);
}}
>
<SelectTrigger>
<SelectValue />
<SelectTrigger className="w-full">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectPopover>
<ListBox>
@@ -99,11 +104,11 @@ export function Sidebar() {
</div>
<ScrollShadow className="flex-1 px-2 py-2" hideScrollBar>
<nav className="flex flex-col gap-4">
<nav className="flex flex-col gap-3">
{navGroups.map((group) => (
<div key={group.label}>
{!collapsed && (
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
<div className="px-2 pb-1 text-[9px] font-semibold uppercase tracking-[0.16em] text-muted">
{group.label}
</div>
)}
@@ -116,12 +121,12 @@ export function Sidebar() {
<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'}`}
className={`h-9 justify-start gap-2.5 px-2.5 text-sm 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}
{item.icon} {!collapsed && <span className="truncate">{item.label}</span>}
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="right" offset={8}>{item.label}</Tooltip.Content>
@@ -134,14 +139,14 @@ export function Sidebar() {
{user?.isAdmin && (
<div>
{!collapsed && (
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
<div className="px-2 pb-1 text-[9px] font-semibold uppercase tracking-[0.16em] text-muted">
Admin
</div>
)}
<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'}`}
className={`h-9 justify-start gap-2.5 px-2.5 text-sm font-medium ${section === 'admin' ? 'bg-accent-soft text-accent-soft-foreground' : 'text-muted'}`}
variant="ghost"
size="sm"
onPress={() => setSection('admin' as any)}
@@ -156,38 +161,32 @@ export function Sidebar() {
</nav>
</ScrollShadow>
<div className="p-3 flex flex-col gap-2">
<Button
isIconOnly
className="w-full"
size="sm"
variant="ghost"
onPress={() => setCollapsed((c) => !c)}
>
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
</Button>
<Card>
<CardContent className={`flex items-center gap-3 p-2 ${collapsed ? 'justify-center' : ''}`}>
<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] text-muted">Angemeldet</div>
</div>
<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>
</>
)}
</CardContent>
</Card>
<div className="border-t border-border p-2">
<div className={`group flex items-center gap-2 rounded-lg p-1.5 transition-colors hover:bg-surface-secondary ${collapsed ? 'justify-center' : ''}`}>
<AppAvatar name={user?.username} size="sm" className="shrink-0" />
{!collapsed && (
<>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{user?.username}</div>
<div className="text-[10px] text-muted">Angemeldet</div>
</div>
<Tooltip>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onPress={handleLogout}
>
<LogOut size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="top">Abmelden</Tooltip.Content>
</Tooltip>
</>
)}
</div>
</div>
</aside>
);

View File

@@ -0,0 +1,32 @@
import { Switch } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
isSelected: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
description?: ReactNode;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg';
'aria-label'?: string;
};
export function AppSwitch({ isSelected, onChange, label, description, isDisabled, size, ...rest }: Props) {
return (
<Switch
isSelected={isSelected}
onChange={onChange}
isDisabled={isDisabled}
size={size}
aria-label={!label ? rest['aria-label'] || 'Umschalten' : undefined}
>
<Switch.Content>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
{label && <span>{label}</span>}
</Switch.Content>
{description && <p data-slot="description" className="text-xs text-muted">{description}</p>}
</Switch>
);
}

View File

@@ -0,0 +1,56 @@
import { Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem } from '@heroui/react';
import { Hash, Volume2, Folder, ChevronDown } from 'lucide-react';
export type ChannelOption = { id: string; name: string; type?: string };
function optionIcon(type?: string) {
if (type === 'voice') return <Volume2 size={14} className="shrink-0 text-muted" />;
if (type === 'category') return <Folder size={14} className="shrink-0 text-muted" />;
return <Hash size={14} className="shrink-0 text-muted" />;
}
type Props = {
options: ChannelOption[];
value?: string;
onChange: (id: string) => void;
placeholder?: string;
'aria-label'?: string;
};
export function ChannelSelect({ options, value, onChange, placeholder = 'Channel wählen', ...rest }: Props) {
const items = value && !options.some((o) => o.id === value)
? [...options, { id: value, name: value, type: 'unknown' }]
: options;
return (
<Select
aria-label={rest['aria-label'] || placeholder}
placeholder={placeholder}
selectedKey={value || null}
onSelectionChange={(key) => { if (typeof key === 'string') onChange(key); }}
>
<SelectTrigger className="w-full justify-between">
<span className="flex min-w-0 flex-1 items-center gap-2">
{optionIcon(items.find((o) => o.id === value)?.type)}
<SelectValue className="truncate" />
</span>
<ChevronDown size={14} className="shrink-0 text-muted" />
</SelectTrigger>
<SelectPopover className="max-h-72 w-[--trigger-width]">
<ListBox>
{items.length ? items.map((o) => (
<ListBoxItem key={o.id} id={o.id} textValue={o.name}>
<span className="flex items-center gap-2">
{optionIcon(o.type)} {o.type === 'unknown' ? `Unbekannt (${o.name})` : o.name}
</span>
</ListBoxItem>
)) : (
<ListBoxItem key="__empty" id="__empty" isDisabled textValue="Keine Channels gefunden">
Keine Channels gefunden
</ListBoxItem>
)}
</ListBox>
</SelectPopover>
</Select>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Bot } from 'lucide-react';
type Props = {
botName?: string;
title?: string;
description?: string;
footer?: string;
accentColor?: string;
children?: ReactNode;
};
const now = () => new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
export function DiscordPreview({ botName = 'Papo', title, description, footer, accentColor = '#5865f2', children }: Props) {
return (
<div className="rounded-lg bg-[#313338] p-4">
<div className="flex gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#5865f2] text-white">
<Bot size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-[15px] font-medium text-white">{botName}</span>
<span className="rounded bg-[#5865f2] px-1 py-px text-[10px] font-medium leading-none text-white">BOT</span>
<span className="text-xs text-[#949ba4]">Heute um {now()}</span>
</div>
{(title || description || footer) && (
<div
className="mt-1 max-w-md rounded border-l-4 bg-[#2b2d31] p-3"
style={{ borderLeftColor: accentColor }}
>
{title && <div className="text-[15px] font-semibold text-white">{title}</div>}
{description && (
<div className="mt-1 whitespace-pre-wrap text-sm leading-snug text-[#dbdee1]">{description}</div>
)}
{footer && <div className="mt-2.5 text-xs text-[#949ba4]">{footer}</div>}
</div>
)}
{children && <div className="mt-2 flex flex-wrap gap-2">{children}</div>}
</div>
</div>
</div>
);
}
export function DiscordButton({ variant = 'primary', children }: { variant?: 'primary' | 'secondary'; children: ReactNode }) {
return (
<div
className={`flex h-8 items-center gap-1.5 rounded px-3 text-sm font-medium text-white ${
variant === 'primary' ? 'bg-[#5865f2]' : 'bg-[#4e5058]'
}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem } from '@heroui/react';
import { ChevronDown, AtSign } from 'lucide-react';
export type RoleOption = { id: string; name: string; color?: string };
type Props = {
options: RoleOption[];
value?: string;
onChange: (id: string) => void;
placeholder?: string;
'aria-label'?: string;
};
export function RoleSelect({ options, value, onChange, placeholder = 'Rolle wählen', ...rest }: Props) {
const items = value && !options.some((o) => o.id === value)
? [...options, { id: value, name: value }]
: options;
const selected = items.find((o) => o.id === value);
return (
<Select
aria-label={rest['aria-label'] || placeholder}
placeholder={placeholder}
selectedKey={value || null}
onSelectionChange={(key) => { if (typeof key === 'string') onChange(key); }}
>
<SelectTrigger className="w-full justify-between">
<span className="flex min-w-0 flex-1 items-center gap-2">
{selected?.color ? (
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: selected.color }} />
) : (
<AtSign size={14} className="shrink-0 text-muted" />
)}
<SelectValue className="truncate" />
</span>
<ChevronDown size={14} className="shrink-0 text-muted" />
</SelectTrigger>
<SelectPopover className="max-h-72 w-[--trigger-width]">
<ListBox>
{items.length ? items.map((o) => (
<ListBoxItem key={o.id} id={o.id} textValue={o.name}>
<span className="flex items-center gap-2">
{o.color ? <span className="size-2.5 rounded-full" style={{ backgroundColor: o.color }} /> : <AtSign size={14} className="text-muted" />}
{o.name}
</span>
</ListBoxItem>
)) : (
<ListBoxItem key="__empty" id="__empty" isDisabled textValue="Keine Rollen gefunden">
Keine Rollen gefunden
</ListBoxItem>
)}
</ListBox>
</SelectPopover>
</Select>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Card, CardHeader, CardContent, Chip } from '@heroui/react';
import { GripVertical } from 'lucide-react';
import { formatDate } from '../../utils/formatters';
type TicketRecord = {
id: string;
topic?: string;
createdAt?: string | number;
category?: string;
[key: string]: unknown;
};
type Column = { key: string; label: string; color: 'warning' | 'accent' | 'default' | 'success' };
const COLUMNS: Column[] = [
{ key: 'neu', label: 'Neu', color: 'warning' },
{ key: 'in_bearbeitung', label: 'In Bearbeitung', color: 'accent' },
{ key: 'warten_auf_user', label: 'Warten auf User', color: 'default' },
{ key: 'erledigt', label: 'Erledigt', color: 'success' },
];
type Props = {
pipeline: Record<string, TicketRecord[]>;
updateTicketStatus: (ticketId: string, status: string) => Promise<void>;
};
export function TicketKanban({ pipeline, updateTicketStatus }: Props) {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
return (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
{COLUMNS.map(({ key, label, color }) => {
const items = pipeline[key] || [];
const isDropTarget = dragOverKey === key;
return (
<Card
key={key}
className={`transition-colors ${isDropTarget ? 'border-accent' : 'border-transparent'} border-2`}
onDragOver={(e) => {
e.preventDefault();
if (dragOverKey !== key) setDragOverKey(key);
}}
onDragLeave={() => setDragOverKey((k) => (k === key ? null : k))}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain') || draggedId;
if (id) updateTicketStatus(id, key);
setDragOverKey(null);
setDraggedId(null);
}}
>
<CardHeader className="px-4 pt-4 pb-0">
<div className="flex items-center gap-2">
<div className={`size-2.5 rounded-full bg-${color}`} />
<h3 className="text-sm font-semibold">{label}</h3>
<Chip size="sm" variant="soft" color={color}>{items.length}</Chip>
</div>
</CardHeader>
<CardContent className="flex min-h-[120px] flex-col gap-2 p-4">
{items.length ? items.map((t) => (
<div
key={t.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', t.id);
e.dataTransfer.effectAllowed = 'move';
setDraggedId(t.id);
}}
onDragEnd={() => {
setDraggedId(null);
setDragOverKey(null);
}}
className={`bg-surface-tertiary flex cursor-grab items-start gap-2 rounded-xl p-3 text-sm active:cursor-grabbing ${
draggedId === t.id ? 'opacity-40' : ''
}`}
>
<GripVertical size={14} className="mt-0.5 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{t.topic || t.id}</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted">
{t.category && <span>{t.category}</span>}
{t.category && <span>·</span>}
<span>{formatDate(t.createdAt)}</span>
</div>
</div>
</div>
)) : (
<p className="py-4 text-center text-xs text-muted">Tickets hierher ziehen</p>
)}
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -1,43 +1,85 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Shield, Filter, Link, Ban, AlertTriangle, Save } from 'lucide-react';
import { Card, CardContent, CardHeader, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Shield, Link, Ban, AlertTriangle, Save, Info } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
const FILTERS = [
{
key: 'badWordFilter' as const,
icon: <Ban size={16} />,
title: 'Bad-Word-Filter',
description: 'Entfernt Nachrichten mit unerwünschten Begriffen automatisch.',
},
{
key: 'linkFilter' as const,
icon: <Link size={16} />,
title: 'Link-Filter',
description: 'Blockiert bekannte schädliche Domains und nicht-whitelistete Links.',
},
{
key: 'spamFilter' as const,
icon: <AlertTriangle size={16} />,
title: 'Spam-Filter',
description: 'Erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.',
},
];
export function Automod() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Filter konfigurieren</h3>
<h3 className="text-base font-semibold">Automod</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={settings.automodEnabled !== false} onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}>
<div className="flex items-center gap-2">
<Shield size={16} /> Automod aktiv
<div className="flex items-center gap-3 rounded-xl border border-accent bg-accent-soft p-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<Shield size={16} />
</div>
</Switch>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">Automod aktiv</div>
<div className="text-xs text-muted">Schaltet alle Filter unten gesammelt ein oder aus.</div>
</div>
<AppSwitch
aria-label="Automod aktiv"
isSelected={settings.automodEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Switch isSelected={settings.automodConfig?.badWordFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), badWordFilter: v } }))}>
<div className="flex items-center gap-2"><Ban size={14} /> Bad-Word-Filter</div>
</Switch>
<Switch isSelected={settings.automodConfig?.linkFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkFilter: v } }))}>
<div className="flex items-center gap-2"><Link size={14} /> Link-Filter</div>
</Switch>
<Switch isSelected={settings.automodConfig?.spamFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), spamFilter: v } }))}>
<div className="flex items-center gap-2"><AlertTriangle size={14} /> Spam-Filter</div>
</Switch>
<div className="flex flex-col gap-2">
{FILTERS.map((f) => (
<div key={f.key} className="bg-surface-secondary flex items-center gap-3 rounded-xl p-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{f.icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={settings.automodConfig?.[f.key] ?? false}
onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), [f.key]: v } }))}
/>
</div>
))}
</div>
<TextField>
<Label>Log Channel ID</Label>
<Input
placeholder="Channel ID für Logs"
value={settings.automodConfig?.logChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: e.target.value } }))}
<Label>Log Channel</Label>
<ChannelSelect
options={channels}
value={settings.automodConfig?.logChannelId}
onChange={(id) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: id } }))}
placeholder="Channel für Automod-Logs wählen"
/>
</TextField>
@@ -60,22 +102,9 @@ export function Automod() {
<div className="flex flex-col gap-4">
<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="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="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="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="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 className="flex items-start gap-3 p-4">
<Info size={16} className="mt-0.5 shrink-0 text-accent" />
<p className="text-sm text-muted">Änderungen werden nach dem Speichern sofort aktiv, ohne dass der Bot neu gestartet werden muss.</p>
</CardContent>
</Card>
</div>

View File

@@ -1,10 +1,14 @@
import { Card, CardContent, CardHeader, Input, InputGroup, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardHeader, InputGroup, TextArea, Button, Chip, 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';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function Birthday() {
const { birthday, setBirthday, saveBirthday } = useApp();
const { birthday, setBirthday, saveBirthday, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Einträge">
@@ -14,16 +18,19 @@ export function Birthday() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={birthday.config?.enabled !== false} onChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}>
<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div>
</Switch>
<AppSwitch
isSelected={birthday.config?.enabled !== false}
onChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}
label={<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div>}
/>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel für Geburtstagsnachrichten"
value={birthday.config?.channelId || ''}
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: e.target.value } }))}
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={birthday.config?.channelId}
onChange={(id) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: id } }))}
placeholder="Channel für Geburtstagsnachrichten wählen"
/>
</TextField>

View File

@@ -1,10 +1,14 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { AudioLines, Save, Mic, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function DynamicVoice() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, categories } = useGuildResources(currentGuildId);
return (
<SectionCard title="Dynamic Voice" subtitle="Voice-Lobby, Template und Limits">
@@ -14,25 +18,29 @@ export function DynamicVoice() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={settings.dynamicVoiceEnabled !== false} onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}>
<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>
</Switch>
<AppSwitch
isSelected={settings.dynamicVoiceEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}
label={<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>}
/>
<TextField>
<Label>Lobby Channel ID</Label>
<Input
placeholder="Channel ID der Lobby"
value={settings.dynamicVoiceConfig?.lobbyChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: e.target.value } }))}
<Label>Lobby Channel</Label>
<ChannelSelect
options={channels.filter((c) => c.type === 'voice')}
value={settings.dynamicVoiceConfig?.lobbyChannelId}
onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: id } }))}
placeholder="Voice-Channel der Lobby wählen"
/>
</TextField>
<TextField>
<Label>Kategorie ID</Label>
<Input
placeholder="Kategorie für neue Channels"
value={settings.dynamicVoiceConfig?.categoryId || ''}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: e.target.value } }))}
<Label>Kategorie</Label>
<ChannelSelect
options={categories.map((c) => ({ ...c, type: 'category' }))}
value={settings.dynamicVoiceConfig?.categoryId}
onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: id } }))}
placeholder="Kategorie für neue Channels wählen"
/>
</TextField>

View File

@@ -3,9 +3,12 @@ import { CalendarDays, Trash2, Plus, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Events() {
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent } = useApp();
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow">
@@ -65,11 +68,12 @@ export function Events() {
</TextField>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel für Erinnerungen"
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={eventDraft.channelId}
onChange={(e) => setEventDraft((s) => ({ ...s, channelId: e.target.value }))}
onChange={(id) => setEventDraft((s) => ({ ...s, channelId: id }))}
placeholder="Channel für Erinnerungen wählen"
/>
</TextField>

View File

@@ -1,7 +1,8 @@
import { Card, CardContent, Switch } from '@heroui/react';
import { Card, CardContent } from '@heroui/react';
import { Puzzle, CheckCircle, XCircle } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
export function ModulesPage() {
const { modules, toggleModule } = useApp();
@@ -28,7 +29,7 @@ export function ModulesPage() {
<div className="text-sm text-muted truncate">{module.description}</div>
)}
</div>
<Switch isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
<AppSwitch aria-label={module.name} isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
</CardContent>
</Card>
))}
@@ -52,7 +53,7 @@ export function ModulesPage() {
<div className="text-sm text-muted truncate">{module.description}</div>
)}
</div>
<Switch isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
<AppSwitch aria-label={module.name} isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
</CardContent>
</Card>
))}

View File

@@ -2,9 +2,12 @@ import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator
import { Tag, Save, Hash, List } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function ReactionRoles() {
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole } = useApp();
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
@@ -49,11 +52,12 @@ export function ReactionRoles() {
</TextField>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel für die Nachricht"
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={reactionDraft.channelId}
onChange={(e) => setReactionDraft((s) => ({ ...s, channelId: e.target.value }))}
onChange={(id) => setReactionDraft((s) => ({ ...s, channelId: id }))}
placeholder="Channel für die Nachricht wählen"
/>
</TextField>

View File

@@ -1,7 +1,8 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { Activity, Save, Trash2, Plus, BarChart3 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
export function ServerStats() {
const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp();
@@ -16,9 +17,11 @@ export function ServerStats() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={statsDraft?.enabled === true} onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}>
<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>
</Switch>
<AppSwitch
isSelected={statsDraft?.enabled === true}
onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>}
/>
<TextField>
<Label>Kategorie-Name</Label>

View File

@@ -1,10 +1,15 @@
import { Card, CardContent, CardHeader, Input, Button, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardHeader, Button, Separator, TextField, Label } from '@heroui/react';
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function SettingsPage() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, roles } = useGuildResources(currentGuildId);
return (
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
@@ -15,29 +20,32 @@ export function SettingsPage() {
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Welcome Channel ID</Label>
<Input
placeholder="Channel ID"
value={settings.welcomeChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeChannelId: e.target.value }))}
<Label>Welcome Channel</Label>
<ChannelSelect
options={channels}
value={settings.welcomeChannelId}
onChange={(id) => setSettings((s) => ({ ...s, welcomeChannelId: id }))}
placeholder="Channel wählen"
/>
</TextField>
<TextField>
<Label>Log Channel ID</Label>
<Input
placeholder="Channel ID"
value={settings.logChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, logChannelId: e.target.value }))}
<Label>Log Channel</Label>
<ChannelSelect
options={channels}
value={settings.logChannelId}
onChange={(id) => setSettings((s) => ({ ...s, logChannelId: id }))}
placeholder="Channel wählen"
/>
</TextField>
<TextField>
<Label>Support Role ID</Label>
<Input
placeholder="Role ID"
value={settings.supportRoleId || ''}
onChange={(e) => setSettings((s) => ({ ...s, supportRoleId: e.target.value }))}
<Label>Support Rolle</Label>
<RoleSelect
options={roles}
value={settings.supportRoleId}
onChange={(id) => setSettings((s) => ({ ...s, supportRoleId: id }))}
placeholder="Rolle wählen"
/>
</TextField>
@@ -54,25 +62,35 @@ export function SettingsPage() {
<h3 className="text-base font-semibold">Logging Kategorien</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={settings.loggingConfig?.categories?.joinLeave !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), joinLeave: v } } }))}>
<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div>
</Switch>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.joinLeave !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), joinLeave: v } } }))}
label={<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div>}
/>
<Switch isSelected={settings.loggingConfig?.categories?.messageEdit !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageEdit: v } } }))}>
<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div>
</Switch>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.messageEdit !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageEdit: v } } }))}
label={<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div>}
/>
<Switch isSelected={settings.loggingConfig?.categories?.messageDelete !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageDelete: v } } }))}>
<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div>
</Switch>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.messageDelete !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageDelete: v } } }))}
label={<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div>}
/>
<Switch isSelected={settings.loggingConfig?.categories?.automodActions !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), automodActions: v } } }))}>
<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div>
</Switch>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.automodActions !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), automodActions: v } } }))}
label={<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div>}
/>
<Switch isSelected={settings.loggingConfig?.categories?.ticketActions !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), ticketActions: v } } }))}>
<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div>
</Switch>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.ticketActions !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), ticketActions: v } } }))}
label={<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div>}
/>
<Separator />

View File

@@ -1,11 +1,15 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { RadioTower, Save, Trash2, Plus, Activity as ActivityIcon } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import type { StatusService } from '../types';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function Statuspage() {
const { statusDraft, setStatusDraft, saveStatuspage, statusServiceDraft, setStatusServiceDraft, addStatusService, deleteStatusService } = useApp();
const { statusDraft, setStatusDraft, saveStatuspage, statusServiceDraft, setStatusServiceDraft, addStatusService, deleteStatusService, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const services = ((statusDraft?.services || []) as StatusService[]);
@@ -17,16 +21,19 @@ export function Statuspage() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={statusDraft?.enabled !== false} onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}>
<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>
</Switch>
<AppSwitch
isSelected={statusDraft?.enabled !== false}
onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>}
/>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel für Status-Updates"
value={statusDraft?.channelId || ''}
onChange={(e) => setStatusDraft((s) => ({ ...(s || {}), channelId: e.target.value }))}
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={statusDraft?.channelId}
onChange={(id) => setStatusDraft((s) => ({ ...(s || {}), channelId: id }))}
placeholder="Channel für Status-Updates wählen"
/>
</TextField>

View File

@@ -1,11 +1,16 @@
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 { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { UserRound, Save, Send } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { DiscordPreview, DiscordButton } from '../components/shared/DiscordPreview';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function SupportLogin() {
const { supportLogin, setSupportLogin, saveSupportLogin } = useApp();
const { supportLogin, setSupportLogin, saveSupportLogin, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren">
@@ -18,19 +23,19 @@ export function SupportLogin() {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Switch
<AppSwitch
isSelected={supportLogin?.config?.autoRefresh !== false}
onChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
>
Auto-Refresh aktiv
</Switch>
label="Auto-Refresh aktiv"
/>
<TextField>
<Label>Panel Channel ID</Label>
<Input
placeholder="Channel ID eingeben"
value={supportLogin?.config?.panelChannelId || ''}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: e.target.value } } : s)}
<Label>Panel Channel</Label>
<ChannelSelect
options={channels}
value={supportLogin?.config?.panelChannelId}
onChange={(id) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: id } } : s)}
placeholder="Channel für das Panel wählen"
/>
</TextField>
@@ -84,25 +89,17 @@ export function SupportLogin() {
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>Panel in einer normalen HeroUI-Karte.</CardDescription>
<CardDescription>So sieht das Panel auf Discord aus.</CardDescription>
</div>
</CardHeader>
<CardContent>
<Card className="bg-surface-tertiary">
<CardHeader>
<div className="flex items-center gap-2">
<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>
</div>
</div>
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm" variant="primary">{supportLogin?.config?.loginLabel || 'Login'}</Button>
<Button size="sm" variant="ghost">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
</CardContent>
</Card>
<DiscordPreview
title={supportLogin?.config?.title || 'Support Login'}
description={supportLogin?.config?.description || 'Melde dich als Support an/ab.'}
>
<DiscordButton variant="primary">{supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}</DiscordButton>
<DiscordButton variant="secondary">{supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}</DiscordButton>
</DiscordPreview>
</CardContent>
</Card>

View File

@@ -6,6 +6,7 @@ import { formatDate } from '../utils/formatters';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
import { TicketKanban } from '../components/shared/TicketKanban';
export function Tickets() {
const {
@@ -52,14 +53,14 @@ export function Tickets() {
<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' :
t.status === 'in-progress' ? 'bg-accent' :
t.status === 'waiting' ? 'bg-default' : 'bg-success'
t.status === 'neu' ? 'bg-warning' :
t.status === 'in_bearbeitung' ? 'bg-accent' :
t.status === 'warten_auf_user' ? 'bg-default' : 'bg-success'
}`} />
<span className="font-semibold text-sm truncate">{t.topic || 'Ticket'}</span>
</div>
<Chip size="sm" variant="soft" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'accent'}>
{t.status || 'open'}
<Chip size="sm" variant="soft" color={t.status === 'neu' ? 'warning' : t.status === 'erledigt' ? 'default' : 'accent'}>
{t.status || 'neu'}
</Chip>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
@@ -81,10 +82,10 @@ export function Tickets() {
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
>
<option value="">Status ändern</option>
<option value="open">Open</option>
<option value="in-progress">In Progress</option>
<option value="waiting">Warten</option>
<option value="closed">Closed</option>
<option value="neu">Neu</option>
<option value="in_bearbeitung">In Bearbeitung</option>
<option value="warten_auf_user">Warten auf User</option>
<option value="erledigt">Erledigt</option>
</select>
<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>
@@ -104,7 +105,7 @@ export function Tickets() {
<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="soft" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'accent'}>
<Chip size="sm" variant="soft" color={t.status === 'neu' ? 'warning' : t.status === 'erledigt' ? 'default' : 'accent'}>
{t.status}
</Chip>
</div>
@@ -150,38 +151,7 @@ export function Tickets() {
)}
{ticketTab === 'pipeline' && (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
{[
{ key: 'neu', label: 'Neu', color: 'warning' as const },
{ key: 'in_bearbeitung', label: 'In Bearbeitung', color: '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}>
<CardHeader className="px-4 pt-4 pb-0">
<div className="flex items-center gap-2">
<div className={`size-2.5 rounded-full bg-${color}`} />
<h3 className="text-sm font-semibold">{label}</h3>
<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="bg-surface-tertiary rounded-xl p-3 text-sm">
<div className="font-medium truncate">{t.topic || t.id}</div>
<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-xs text-muted text-center py-4">Keine Tickets</p>}
</CardContent>
</Card>
))}
</div>
<TicketKanban pipeline={pipeline} updateTicketStatus={updateTicketStatus} />
)}
{ticketTab === 'sla' && (

View File

@@ -1,10 +1,15 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Switch, Separator, TextField, Label } from '@heroui/react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Sparkles, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function Welcome() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
@@ -17,16 +22,19 @@ export function Welcome() {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Switch isSelected={settings.welcomeConfig?.enabled !== false} onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}>
<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div>
</Switch>
<AppSwitch
isSelected={settings.welcomeConfig?.enabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}
label={<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div>}
/>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel ID fuer Willkommensnachrichten"
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: e.target.value } }))}
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId}
onChange={(id) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: id } }))}
placeholder="Channel für Willkommensnachrichten wählen"
/>
</TextField>
@@ -69,24 +77,15 @@ export function Welcome() {
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>Normale HeroUI-Karten ohne zusaetzliche Huelle.</CardDescription>
<CardDescription>So sieht die Nachricht auf Discord aus.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Card className="bg-surface-tertiary">
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-accent" />
<CardTitle>{settings.welcomeConfig?.embedTitle || 'Willkommen!'}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-foreground/80">{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
{settings.welcomeConfig?.embedFooter && (
<p className="text-sm text-muted">{settings.welcomeConfig.embedFooter}</p>
)}
</CardContent>
</Card>
<DiscordPreview
title={settings.welcomeConfig?.embedTitle || 'Willkommen!'}
description={settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}
footer={settings.welcomeConfig?.embedFooter}
/>
<p className="text-sm text-muted">
Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen.