improve dashboard usability and fix backend TS build errors
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:
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
32
frontend/src/components/shared/AppSwitch.tsx
Normal file
32
frontend/src/components/shared/AppSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/shared/ChannelSelect.tsx
Normal file
56
frontend/src/components/shared/ChannelSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/shared/DiscordPreview.tsx
Normal file
59
frontend/src/components/shared/DiscordPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/shared/RoleSelect.tsx
Normal file
56
frontend/src/components/shared/RoleSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/shared/TicketKanban.tsx
Normal file
98
frontend/src/components/shared/TicketKanban.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user