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 *)); @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 { html, body, #root {
min-height: 100%; min-height: 100%;
} }

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Card, CardContent, ScrollShadow, Tooltip, Button, ScrollShadow, Tooltip,
Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem
} from '@heroui/react'; } from '@heroui/react';
import { import {
@@ -64,18 +64,23 @@ export function Sidebar() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-20' : 'w-60'}`}> <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-3 px-4 pt-4 pb-3 ${collapsed ? 'justify-center' : ''}`}> <div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
<div className="bg-accent text-accent-foreground flex size-10 items-center justify-center rounded-2xl font-black">P</div> <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 && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-base font-bold">Papo</div> <div className="text-sm font-bold leading-tight">Papo</div>
<div className="text-[10px] uppercase tracking-widest text-muted">Dashboard</div> <div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
</div> </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 <Select
aria-label="Guild auswaehlen" aria-label="Guild auswaehlen"
selectedKey={currentGuildId} selectedKey={currentGuildId}
@@ -83,8 +88,8 @@ export function Sidebar() {
if (typeof key === 'string') setCurrentGuildId(key); if (typeof key === 'string') setCurrentGuildId(key);
}} }}
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue className="truncate" />
</SelectTrigger> </SelectTrigger>
<SelectPopover> <SelectPopover>
<ListBox> <ListBox>
@@ -99,11 +104,11 @@ export function Sidebar() {
</div> </div>
<ScrollShadow className="flex-1 px-2 py-2" hideScrollBar> <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) => ( {navGroups.map((group) => (
<div key={group.label}> <div key={group.label}>
{!collapsed && ( {!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} {group.label}
</div> </div>
)} )}
@@ -116,12 +121,12 @@ export function Sidebar() {
<Tooltip key={item.key} isDisabled={!collapsed}> <Tooltip key={item.key} isDisabled={!collapsed}>
<Tooltip.Trigger> <Tooltip.Trigger>
<Button <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" variant="ghost"
size="sm" size="sm"
onPress={() => setSection(item.key as any)} onPress={() => setSection(item.key as any)}
> >
{item.icon} {!collapsed && item.label} {item.icon} {!collapsed && <span className="truncate">{item.label}</span>}
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content placement="right" offset={8}>{item.label}</Tooltip.Content> <Tooltip.Content placement="right" offset={8}>{item.label}</Tooltip.Content>
@@ -134,14 +139,14 @@ export function Sidebar() {
{user?.isAdmin && ( {user?.isAdmin && (
<div> <div>
{!collapsed && ( {!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 Admin
</div> </div>
)} )}
<Tooltip isDisabled={!collapsed}> <Tooltip isDisabled={!collapsed}>
<Tooltip.Trigger> <Tooltip.Trigger>
<Button <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" variant="ghost"
size="sm" size="sm"
onPress={() => setSection('admin' as any)} onPress={() => setSection('admin' as any)}
@@ -156,29 +161,24 @@ export function Sidebar() {
</nav> </nav>
</ScrollShadow> </ScrollShadow>
<div className="p-3 flex flex-col gap-2"> <div className="border-t border-border p-2">
<Button <div className={`group flex items-center gap-2 rounded-lg p-1.5 transition-colors hover:bg-surface-secondary ${collapsed ? 'justify-center' : ''}`}>
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" /> <AppAvatar name={user?.username} size="sm" className="shrink-0" />
{!collapsed && ( {!collapsed && (
<> <>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{user?.username}</div> <div className="truncate text-xs font-semibold">{user?.username}</div>
<div className="text-[10px] text-muted">Angemeldet</div> <div className="text-[10px] text-muted">Angemeldet</div>
</div> </div>
<Tooltip> <Tooltip>
<Tooltip.Trigger> <Tooltip.Trigger>
<Button isIconOnly size="sm" variant="ghost" onPress={handleLogout}> <Button
isIconOnly
size="sm"
variant="ghost"
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onPress={handleLogout}
>
<LogOut size={14} /> <LogOut size={14} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -186,8 +186,7 @@ export function Sidebar() {
</Tooltip> </Tooltip>
</> </>
)} )}
</CardContent> </div>
</Card>
</div> </div>
</aside> </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 { Card, CardContent, CardHeader, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Shield, Filter, Link, Ban, AlertTriangle, Save } from 'lucide-react'; import { Shield, Link, Ban, AlertTriangle, Save, Info } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; 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() { export function Automod() {
const { settings, setSettings, saveSettingsPayload } = useApp(); const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit"> <SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
<div className="grid gap-5 xl:grid-cols-2"> <div className="grid gap-5 xl:grid-cols-2">
<Card> <Card>
<CardHeader className="px-5 pt-5 pb-0"> <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> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <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-3 rounded-xl border border-accent bg-accent-soft p-3">
<div className="flex items-center gap-2"> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<Shield size={16} /> Automod aktiv <Shield size={16} />
</div>
<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>
</Switch>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-col gap-2">
<Switch isSelected={settings.automodConfig?.badWordFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), badWordFilter: v } }))}> {FILTERS.map((f) => (
<div className="flex items-center gap-2"><Ban size={14} /> Bad-Word-Filter</div> <div key={f.key} className="bg-surface-secondary flex items-center gap-3 rounded-xl p-3">
</Switch> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
<Switch isSelected={settings.automodConfig?.linkFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkFilter: v } }))}> {f.icon}
<div className="flex items-center gap-2"><Link size={14} /> Link-Filter</div> </div>
</Switch> <div className="min-w-0 flex-1">
<Switch isSelected={settings.automodConfig?.spamFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), spamFilter: v } }))}> <div className="text-sm font-medium">{f.title}</div>
<div className="flex items-center gap-2"><AlertTriangle size={14} /> Spam-Filter</div> <div className="text-xs text-muted">{f.description}</div>
</Switch> </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> </div>
<TextField> <TextField>
<Label>Log Channel ID</Label> <Label>Log Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID für Logs" options={channels}
value={settings.automodConfig?.logChannelId || ''} value={settings.automodConfig?.logChannelId}
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: e.target.value } }))} onChange={(id) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: id } }))}
placeholder="Channel für Automod-Logs wählen"
/> />
</TextField> </TextField>
@@ -60,22 +102,9 @@ export function Automod() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card> <Card>
<CardHeader className="px-5 pt-5 pb-0"> <CardContent className="flex items-start gap-3 p-4">
<h3 className="text-base font-semibold">Info</h3> <Info size={16} className="mt-0.5 shrink-0 text-accent" />
</CardHeader> <p className="text-sm text-muted">Änderungen werden nach dem Speichern sofort aktiv, ohne dass der Bot neu gestartet werden muss.</p>
<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> </CardContent>
</Card> </Card>
</div> </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 { CalendarDays, Save, Cake, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; 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() { export function Birthday() {
const { birthday, setBirthday, saveBirthday } = useApp(); const { birthday, setBirthday, saveBirthday, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Einträge"> <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> <h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <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 } }))}> <AppSwitch
<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div> isSelected={birthday.config?.enabled !== false}
</Switch> 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> <TextField>
<Label>Channel ID</Label> <Label>Channel</Label>
<Input <ChannelSelect
placeholder="Channel für Geburtstagsnachrichten" options={channels}
value={birthday.config?.channelId || ''} value={birthday.config?.channelId}
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: e.target.value } }))} onChange={(id) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: id } }))}
placeholder="Channel für Geburtstagsnachrichten wählen"
/> />
</TextField> </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 { AudioLines, Save, Mic, Users } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; 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() { export function DynamicVoice() {
const { settings, setSettings, saveSettingsPayload } = useApp(); const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, categories } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Dynamic Voice" subtitle="Voice-Lobby, Template und Limits"> <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> <h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={settings.dynamicVoiceEnabled !== false} onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}> <AppSwitch
<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div> isSelected={settings.dynamicVoiceEnabled !== false}
</Switch> onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}
label={<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>}
/>
<TextField> <TextField>
<Label>Lobby Channel ID</Label> <Label>Lobby Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID der Lobby" options={channels.filter((c) => c.type === 'voice')}
value={settings.dynamicVoiceConfig?.lobbyChannelId || ''} value={settings.dynamicVoiceConfig?.lobbyChannelId}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: e.target.value } }))} onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: id } }))}
placeholder="Voice-Channel der Lobby wählen"
/> />
</TextField> </TextField>
<TextField> <TextField>
<Label>Kategorie ID</Label> <Label>Kategorie</Label>
<Input <ChannelSelect
placeholder="Kategorie für neue Channels" options={categories.map((c) => ({ ...c, type: 'category' }))}
value={settings.dynamicVoiceConfig?.categoryId || ''} value={settings.dynamicVoiceConfig?.categoryId}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: e.target.value } }))} onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: id } }))}
placeholder="Kategorie für neue Channels wählen"
/> />
</TextField> </TextField>

View File

@@ -3,9 +3,12 @@ import { CalendarDays, Trash2, Plus, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters'; import { formatDate } from '../utils/formatters';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Events() { export function Events() {
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent } = useApp(); const { events, eventDraft, setEventDraft, saveEvent, deleteEvent, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow"> <SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow">
@@ -65,11 +68,12 @@ export function Events() {
</TextField> </TextField>
<TextField> <TextField>
<Label>Channel ID</Label> <Label>Channel</Label>
<Input <ChannelSelect
placeholder="Channel für Erinnerungen" options={channels}
value={eventDraft.channelId} 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> </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 { Puzzle, CheckCircle, XCircle } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
export function ModulesPage() { export function ModulesPage() {
const { modules, toggleModule } = useApp(); const { modules, toggleModule } = useApp();
@@ -28,7 +29,7 @@ export function ModulesPage() {
<div className="text-sm text-muted truncate">{module.description}</div> <div className="text-sm text-muted truncate">{module.description}</div>
)} )}
</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> </CardContent>
</Card> </Card>
))} ))}
@@ -52,7 +53,7 @@ export function ModulesPage() {
<div className="text-sm text-muted truncate">{module.description}</div> <div className="text-sm text-muted truncate">{module.description}</div>
)} )}
</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> </CardContent>
</Card> </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 { Tag, Save, Hash, List } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function ReactionRoles() { export function ReactionRoles() {
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole } = useApp(); const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen"> <SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
@@ -49,11 +52,12 @@ export function ReactionRoles() {
</TextField> </TextField>
<TextField> <TextField>
<Label>Channel ID</Label> <Label>Channel</Label>
<Input <ChannelSelect
placeholder="Channel für die Nachricht" options={channels}
value={reactionDraft.channelId} 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> </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 { Activity, Save, Trash2, Plus, BarChart3 } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
export function ServerStats() { export function ServerStats() {
const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp(); const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp();
@@ -16,9 +17,11 @@ export function ServerStats() {
<h3 className="text-base font-semibold">Konfiguration</h3> <h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={statsDraft?.enabled === true} onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}> <AppSwitch
<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div> isSelected={statsDraft?.enabled === true}
</Switch> onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>}
/>
<TextField> <TextField>
<Label>Kategorie-Name</Label> <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 { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; 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() { export function SettingsPage() {
const { settings, setSettings, saveSettingsPayload } = useApp(); const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, roles } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien"> <SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
@@ -15,29 +20,32 @@ export function SettingsPage() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <CardContent className="flex flex-col gap-4 p-5">
<TextField> <TextField>
<Label>Welcome Channel ID</Label> <Label>Welcome Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID" options={channels}
value={settings.welcomeChannelId || ''} value={settings.welcomeChannelId}
onChange={(e) => setSettings((s) => ({ ...s, welcomeChannelId: e.target.value }))} onChange={(id) => setSettings((s) => ({ ...s, welcomeChannelId: id }))}
placeholder="Channel wählen"
/> />
</TextField> </TextField>
<TextField> <TextField>
<Label>Log Channel ID</Label> <Label>Log Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID" options={channels}
value={settings.logChannelId || ''} value={settings.logChannelId}
onChange={(e) => setSettings((s) => ({ ...s, logChannelId: e.target.value }))} onChange={(id) => setSettings((s) => ({ ...s, logChannelId: id }))}
placeholder="Channel wählen"
/> />
</TextField> </TextField>
<TextField> <TextField>
<Label>Support Role ID</Label> <Label>Support Rolle</Label>
<Input <RoleSelect
placeholder="Role ID" options={roles}
value={settings.supportRoleId || ''} value={settings.supportRoleId}
onChange={(e) => setSettings((s) => ({ ...s, supportRoleId: e.target.value }))} onChange={(id) => setSettings((s) => ({ ...s, supportRoleId: id }))}
placeholder="Rolle wählen"
/> />
</TextField> </TextField>
@@ -54,25 +62,35 @@ export function SettingsPage() {
<h3 className="text-base font-semibold">Logging Kategorien</h3> <h3 className="text-base font-semibold">Logging Kategorien</h3>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <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 } } }))}> <AppSwitch
<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div> isSelected={settings.loggingConfig?.categories?.joinLeave !== false}
</Switch> 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 } } }))}> <AppSwitch
<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div> isSelected={settings.loggingConfig?.categories?.messageEdit !== false}
</Switch> 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 } } }))}> <AppSwitch
<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div> isSelected={settings.loggingConfig?.categories?.messageDelete !== false}
</Switch> 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 } } }))}> <AppSwitch
<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div> isSelected={settings.loggingConfig?.categories?.automodActions !== false}
</Switch> 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 } } }))}> <AppSwitch
<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div> isSelected={settings.loggingConfig?.categories?.ticketActions !== false}
</Switch> 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 /> <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 { RadioTower, Save, Trash2, Plus, Activity as ActivityIcon } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import type { StatusService } from '../types'; 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() { 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[]); const services = ((statusDraft?.services || []) as StatusService[]);
@@ -17,16 +21,19 @@ export function Statuspage() {
<h3 className="text-base font-semibold">Konfiguration</h3> <h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 p-5"> <CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={statusDraft?.enabled !== false} onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}> <AppSwitch
<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div> isSelected={statusDraft?.enabled !== false}
</Switch> onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>}
/>
<TextField> <TextField>
<Label>Channel ID</Label> <Label>Channel</Label>
<Input <ChannelSelect
placeholder="Channel für Status-Updates" options={channels}
value={statusDraft?.channelId || ''} value={statusDraft?.channelId}
onChange={(e) => setStatusDraft((s) => ({ ...(s || {}), channelId: e.target.value }))} onChange={(id) => setStatusDraft((s) => ({ ...(s || {}), channelId: id }))}
placeholder="Channel für Status-Updates wählen"
/> />
</TextField> </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 { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { LogIn, UserRound, Save, Send } from 'lucide-react'; import { UserRound, Save, Send } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { AppAvatar } from '../components/shared/AppAvatar'; 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() { export function SupportLogin() {
const { supportLogin, setSupportLogin, saveSupportLogin } = useApp(); const { supportLogin, setSupportLogin, saveSupportLogin, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren"> <SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren">
@@ -18,19 +23,19 @@ export function SupportLogin() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Switch <AppSwitch
isSelected={supportLogin?.config?.autoRefresh !== false} isSelected={supportLogin?.config?.autoRefresh !== false}
onChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)} onChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
> label="Auto-Refresh aktiv"
Auto-Refresh aktiv />
</Switch>
<TextField> <TextField>
<Label>Panel Channel ID</Label> <Label>Panel Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID eingeben" options={channels}
value={supportLogin?.config?.panelChannelId || ''} value={supportLogin?.config?.panelChannelId}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: e.target.value } } : s)} onChange={(id) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: id } } : s)}
placeholder="Channel für das Panel wählen"
/> />
</TextField> </TextField>
@@ -84,25 +89,17 @@ export function SupportLogin() {
<CardHeader> <CardHeader>
<div> <div>
<CardTitle>Live Vorschau</CardTitle> <CardTitle>Live Vorschau</CardTitle>
<CardDescription>Panel in einer normalen HeroUI-Karte.</CardDescription> <CardDescription>So sieht das Panel auf Discord aus.</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Card className="bg-surface-tertiary"> <DiscordPreview
<CardHeader> title={supportLogin?.config?.title || 'Support Login'}
<div className="flex items-center gap-2"> description={supportLogin?.config?.description || 'Melde dich als Support an/ab.'}
<LogIn size={16} className="text-accent" /> >
<div> <DiscordButton variant="primary">{supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}</DiscordButton>
<CardTitle>{supportLogin?.config?.title || 'Support Login'}</CardTitle> <DiscordButton variant="secondary">{supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}</DiscordButton>
<CardDescription>{supportLogin?.config?.description || 'Melde dich als Support an/ab.'}</CardDescription> </DiscordPreview>
</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>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -6,6 +6,7 @@ import { formatDate } from '../utils/formatters';
import { SectionCard } from '../components/shared/SectionCard'; import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard'; import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState'; import { EmptyState } from '../components/shared/EmptyState';
import { TicketKanban } from '../components/shared/TicketKanban';
export function Tickets() { export function Tickets() {
const { const {
@@ -52,14 +53,14 @@ export function Tickets() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className={`size-2 rounded-full shrink-0 ${ <div className={`size-2 rounded-full shrink-0 ${
t.status === 'open' ? 'bg-warning' : t.status === 'neu' ? 'bg-warning' :
t.status === 'in-progress' ? 'bg-accent' : t.status === 'in_bearbeitung' ? 'bg-accent' :
t.status === 'waiting' ? 'bg-default' : 'bg-success' t.status === 'warten_auf_user' ? 'bg-default' : 'bg-success'
}`} /> }`} />
<span className="font-semibold text-sm truncate">{t.topic || 'Ticket'}</span> <span className="font-semibold text-sm truncate">{t.topic || 'Ticket'}</span>
</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 || 'open'} {t.status || 'neu'}
</Chip> </Chip>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted"> <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); }} onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
> >
<option value="">Status ändern</option> <option value="">Status ändern</option>
<option value="open">Open</option> <option value="neu">Neu</option>
<option value="in-progress">In Progress</option> <option value="in_bearbeitung">In Bearbeitung</option>
<option value="waiting">Warten</option> <option value="warten_auf_user">Warten auf User</option>
<option value="closed">Closed</option> <option value="erledigt">Erledigt</option>
</select> </select>
<Button size="sm" variant="danger" onPress={() => closeTicket(t.id)}>Schließen</Button> <Button size="sm" variant="danger" onPress={() => closeTicket(t.id)}>Schließen</Button>
<Button size="sm" variant="tertiary" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button> <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-sm font-medium truncate">{t.topic || t.id}</div>
<div className="text-xs text-muted">{t.category || '-'} · {formatDate(t.createdAt)}</div> <div className="text-xs text-muted">{t.category || '-'} · {formatDate(t.createdAt)}</div>
</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} {t.status}
</Chip> </Chip>
</div> </div>
@@ -150,38 +151,7 @@ export function Tickets() {
)} )}
{ticketTab === 'pipeline' && ( {ticketTab === 'pipeline' && (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4"> <TicketKanban pipeline={pipeline} updateTicketStatus={updateTicketStatus} />
{[
{ 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>
)} )}
{ticketTab === 'sla' && ( {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 { Sparkles, Save } from 'lucide-react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard'; 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() { export function Welcome() {
const { settings, setSettings, saveSettingsPayload } = useApp(); const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return ( return (
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten"> <SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
@@ -17,16 +22,19 @@ export function Welcome() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Switch isSelected={settings.welcomeConfig?.enabled !== false} onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}> <AppSwitch
<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div> isSelected={settings.welcomeConfig?.enabled !== false}
</Switch> 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> <TextField>
<Label>Channel ID</Label> <Label>Channel</Label>
<Input <ChannelSelect
placeholder="Channel ID fuer Willkommensnachrichten" options={channels}
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''} value={settings.welcomeConfig?.channelId || settings.welcomeChannelId}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: e.target.value } }))} onChange={(id) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: id } }))}
placeholder="Channel für Willkommensnachrichten wählen"
/> />
</TextField> </TextField>
@@ -69,24 +77,15 @@ export function Welcome() {
<CardHeader> <CardHeader>
<div> <div>
<CardTitle>Live Vorschau</CardTitle> <CardTitle>Live Vorschau</CardTitle>
<CardDescription>Normale HeroUI-Karten ohne zusaetzliche Huelle.</CardDescription> <CardDescription>So sieht die Nachricht auf Discord aus.</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Card className="bg-surface-tertiary"> <DiscordPreview
<CardHeader> title={settings.welcomeConfig?.embedTitle || 'Willkommen!'}
<div className="flex items-center gap-2"> description={settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}
<Sparkles size={16} className="text-accent" /> footer={settings.welcomeConfig?.embedFooter}
<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>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen. Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen.

View File

@@ -10,11 +10,12 @@ const command: SlashCommand = {
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
const amount = interaction.options.getInteger('amount', true); const amount = interaction.options.getInteger('amount', true);
if (!interaction.channel || amount < 1 || amount > 100) { const channel = interaction.channel;
if (!channel || !('bulkDelete' in channel) || amount < 1 || amount > 100) {
await interaction.reply({ content: 'Anzahl muss zwischen 1 und 100 liegen.', ephemeral: true }); await interaction.reply({ content: 'Anzahl muss zwischen 1 und 100 liegen.', ephemeral: true });
return; return;
} }
const messages = await interaction.channel.bulkDelete(amount, true); const messages = await channel.bulkDelete(amount, true);
await interaction.reply({ content: `Gelöschte Nachrichten: ${messages.size}`, ephemeral: true }); await interaction.reply({ content: `Gelöschte Nachrichten: ${messages.size}`, ephemeral: true });
} }
}; };

View File

@@ -13,7 +13,7 @@ const command: SlashCommand = {
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
const target = (interaction.options.getChannel('channel') as TextChannel | null) ?? interaction.channel; const target = (interaction.options.getChannel('channel') as TextChannel | null) ?? interaction.channel;
if (!target || !target.isTextBased()) { if (!target || !target.isTextBased() || !('send' in target)) {
await interaction.reply({ content: 'Bitte wähle einen Textkanal.', ephemeral: true }); await interaction.reply({ content: 'Bitte wähle einen Textkanal.', ephemeral: true });
return; return;
} }

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js'; import { SlashCommandBuilder, ChatInputCommandInteraction, TextChannel } from 'discord.js';
import { SlashCommand } from '../../utils/types'; import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context'; import { context } from '../../config/context';
import { prisma } from '../../database/index'; import { prisma } from '../../database/index';
@@ -16,7 +16,7 @@ const command: SlashCommand = {
return; return;
} }
const filePath = await context.tickets.exportTranscript(interaction.channel!, ticket.id); const filePath = await context.tickets.exportTranscript(interaction.channel as TextChannel, ticket.id);
const fileName = path.basename(filePath); const fileName = path.basename(filePath);
await interaction.reply({ content: `Transcript exportiert: ${fileName}`, files: [fs.createReadStream(filePath)] }); await interaction.reply({ content: `Transcript exportiert: ${fileName}`, files: [fs.createReadStream(filePath)] });
} }

View File

@@ -68,7 +68,9 @@ const command: SlashCommand = {
guildSetting.dynamicVoiceConfig = { guildSetting.dynamicVoiceConfig = {
...(guildSetting.dynamicVoiceConfig ?? {}), ...(guildSetting.dynamicVoiceConfig ?? {}),
lobbyChannelId: voiceLobby?.id ?? guildSetting.dynamicVoiceConfig?.lobbyChannelId, lobbyChannelId: voiceLobby?.id ?? guildSetting.dynamicVoiceConfig?.lobbyChannelId,
categoryId: voiceLobby?.parentId ?? guildSetting.dynamicVoiceConfig?.categoryId, categoryId:
(voiceLobby && 'parentId' in voiceLobby ? voiceLobby.parentId : undefined) ??
guildSetting.dynamicVoiceConfig?.categoryId,
template: voiceTemplate ?? guildSetting.dynamicVoiceConfig?.template, template: voiceTemplate ?? guildSetting.dynamicVoiceConfig?.template,
userLimit: voiceUserLimit ?? guildSetting.dynamicVoiceConfig?.userLimit userLimit: voiceUserLimit ?? guildSetting.dynamicVoiceConfig?.userLimit
}; };

View File

@@ -11,7 +11,7 @@ const command: SlashCommand = {
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return; if (!interaction.guildId) return;
const user = interaction.options.getUser('user') ?? interaction.user; const user = interaction.options.getUser('user') ?? interaction.user;
const level = context.leveling.getLevel(user.id, interaction.guildId); const level = await context.leveling.getLevel(user.id, interaction.guildId);
await interaction.reply({ content: `${user.tag}: Level ${level.level}, XP ${level.xp}` }); await interaction.reply({ content: `${user.tag}: Level ${level.level}, XP ${level.xp}` });
} }
}; };

View File

@@ -69,8 +69,12 @@ context.modules.setHooks({
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined) onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
}, },
serverStatsEnabled: { serverStatsEnabled: {
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined), onEnable: async (guildId: string) => {
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined) await context.stats.refreshGuild(guildId).catch(() => undefined);
},
onDisable: async (guildId: string) => {
await context.stats.disableGuild(guildId).catch(() => undefined);
}
} }
}); });

View File

@@ -1,4 +1,5 @@
import { prisma } from '../database'; import { prisma } from '../database';
import { Prisma } from '@prisma/client';
export interface GuildSettings { export interface GuildSettings {
welcomeChannelId?: string; welcomeChannelId?: string;
@@ -209,14 +210,14 @@ class SettingsStore {
statuspageEnabled: merged.statuspageEnabled ?? null, statuspageEnabled: merged.statuspageEnabled ?? null,
statuspageConfig: merged.statuspageConfig ?? null, statuspageConfig: merged.statuspageConfig ?? null,
dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null, dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null,
dynamicVoiceConfig: merged.dynamicVoiceConfig ?? null, dynamicVoiceConfig: merged.dynamicVoiceConfig ?? Prisma.JsonNull,
eventsEnabled: (merged as any).eventsEnabled ?? null, eventsEnabled: (merged as any).eventsEnabled ?? null,
birthdayEnabled: merged.birthdayEnabled ?? null, birthdayEnabled: merged.birthdayEnabled ?? null,
birthdayConfig: merged.birthdayConfig ?? null, birthdayConfig: merged.birthdayConfig ?? Prisma.JsonNull,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null, reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? Prisma.JsonNull,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null, serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null, serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
@@ -233,14 +234,14 @@ class SettingsStore {
statuspageEnabled: merged.statuspageEnabled ?? null, statuspageEnabled: merged.statuspageEnabled ?? null,
statuspageConfig: merged.statuspageConfig ?? null, statuspageConfig: merged.statuspageConfig ?? null,
dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null, dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null,
dynamicVoiceConfig: merged.dynamicVoiceConfig ?? null, dynamicVoiceConfig: merged.dynamicVoiceConfig ?? Prisma.JsonNull,
eventsEnabled: (merged as any).eventsEnabled ?? null, eventsEnabled: (merged as any).eventsEnabled ?? null,
birthdayEnabled: merged.birthdayEnabled ?? null, birthdayEnabled: merged.birthdayEnabled ?? null,
birthdayConfig: merged.birthdayConfig ?? null, birthdayConfig: merged.birthdayConfig ?? Prisma.JsonNull,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null, reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? Prisma.JsonNull,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null, serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null, serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null

View File

@@ -129,9 +129,11 @@ export class AutoModService {
private async deleteMessageWithReason(message: Message, response: string) { private async deleteMessageWithReason(message: Message, response: string) {
await message.delete().catch(() => undefined); await message.delete().catch(() => undefined);
await message.channel const channel = message.channel;
if (!('send' in channel)) return;
await channel
.send({ content: response }) .send({ content: response })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)) .then((m: Message) => setTimeout(() => m.delete().catch(() => undefined), 5000))
.catch(() => undefined); .catch(() => undefined);
} }

View File

@@ -46,7 +46,7 @@ export class CommandHandler {
for (const file of commandFiles) { for (const file of commandFiles) {
const mod = await import(file); const mod = await import(file);
const command: SlashCommand = mod.default; const command: SlashCommand = mod.default;
if (command?.data && command?.execute) { if (command?.data && typeof command?.execute === 'function') {
this.commands.set(command.data.name, command); this.commands.set(command.data.name, command);
logger.info(`Loaded command ${command.data.name}`); logger.info(`Loaded command ${command.data.name}`);
} }

View File

@@ -32,7 +32,9 @@ export class LevelService {
const nextLevel = Math.floor(0.2 * Math.sqrt(entry.xp)); const nextLevel = Math.floor(0.2 * Math.sqrt(entry.xp));
if (nextLevel > entry.level) { if (nextLevel > entry.level) {
entry.level = nextLevel; entry.level = nextLevel;
if ('send' in message.channel) {
message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined); message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined);
}
logger.info(`Level up: ${message.author.tag} -> ${entry.level}`); logger.info(`Level up: ${message.author.tag} -> ${entry.level}`);
} }
this.cache.set(key, entry); this.cache.set(key, entry);

View File

@@ -1,4 +1,4 @@
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder, GuildChannel } from 'discord.js'; import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder, GuildChannel, GuildBasedChannel } from 'discord.js';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
import type { AdminService } from './adminService'; import type { AdminService } from './adminService';
@@ -134,9 +134,10 @@ export class LoggingService {
if (!this.shouldLog(resolvedGuild, 'automodActions')) return; if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(resolvedGuild); const { channel } = this.resolve(resolvedGuild);
if (!channel) return; if (!channel) return;
const userTag = user instanceof GuildMember ? user.user.tag : user.tag;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Moderation') .setTitle('Moderation')
.setDescription(`${user.tag} -> ${action}`) .setDescription(`${userTag} -> ${action}`)
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') }) .addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
.setColor(0x7289da) .setColor(0x7289da)
.setTimestamp(); .setTimestamp();
@@ -146,7 +147,7 @@ export class LoggingService {
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId, guildId,
level: 'INFO', level: 'INFO',
message: `Moderation action: ${action} (${user.tag})`, message: `Moderation action: ${action} (${userTag})`,
timestamp: Date.now(), timestamp: Date.now(),
category: 'automodActions' category: 'automodActions'
}); });
@@ -154,7 +155,7 @@ export class LoggingService {
} }
} }
logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) { logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildBasedChannel | null; messageUrl?: string }) {
if (!this.shouldLog(guild, 'automodActions')) return; if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild); const { channel } = this.resolve(guild);
if (!channel) return; if (!channel) return;

View File

@@ -235,7 +235,7 @@ export class MusicService {
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed) return null; if (!trimmed) return null;
try { try {
let validation: string | null = null; let validation: Awaited<ReturnType<typeof play.validate>> | null = null;
try { try {
validation = await play.validate(trimmed); validation = await play.validate(trimmed);
} catch (err) { } catch (err) {
@@ -256,7 +256,7 @@ export class MusicService {
if (scSearch && scSearch.length) { if (scSearch && scSearch.length) {
const sc = scSearch[0]; const sc = scSearch[0];
const url = sc.url || ''; const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url }; if (url && /^https?:\/\//i.test(url)) return { title: sc.name ?? 'Unbekannt', url };
} }
return null; return null;
} }

View File

@@ -32,7 +32,7 @@ export class ReactionRoleService {
this.cache.clear(); this.cache.clear();
const sets = await prisma.reactionRoleSet.findMany({ where: { messageId: { not: null } } }); const sets = await prisma.reactionRoleSet.findMany({ where: { messageId: { not: null } } });
sets.forEach((set) => { sets.forEach((set) => {
if (set.messageId) this.cache.set(set.messageId, set.entries as ReactionRoleEntry[]); if (set.messageId) this.cache.set(set.messageId, set.entries as unknown as ReactionRoleEntry[]);
}); });
} }
@@ -105,22 +105,22 @@ export class ReactionRoleService {
if (!enabled) return; if (!enabled) return;
const sets = await prisma.reactionRoleSet.findMany({ where: { guildId } }); const sets = await prisma.reactionRoleSet.findMany({ where: { guildId } });
for (const set of sets) { for (const set of sets) {
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]); const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as unknown as ReactionRoleEntry[]);
if (messageId && messageId !== set.messageId) { if (messageId && messageId !== set.messageId) {
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } }); await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
} }
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]); if (messageId) this.cache.set(messageId, set.entries as unknown as ReactionRoleEntry[]);
} }
} }
public async ensureMessage(guildId: string, id: string) { public async ensureMessage(guildId: string, id: string) {
const set = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } }); const set = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } });
if (!set) return null; if (!set) return null;
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]); const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as unknown as ReactionRoleEntry[]);
if (messageId && messageId !== set.messageId) { if (messageId && messageId !== set.messageId) {
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } }); await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
} }
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]); if (messageId) this.cache.set(messageId, set.entries as unknown as ReactionRoleEntry[]);
return messageId; return messageId;
} }
@@ -128,7 +128,7 @@ export class ReactionRoleService {
if (this.cache.has(messageId)) return this.cache.get(messageId); if (this.cache.has(messageId)) return this.cache.get(messageId);
const set = await prisma.reactionRoleSet.findFirst({ where: { messageId } }); const set = await prisma.reactionRoleSet.findFirst({ where: { messageId } });
if (set) { if (set) {
const entries = (set.entries as ReactionRoleEntry[]) || []; const entries = (set.entries as unknown as ReactionRoleEntry[]) || [];
this.cache.set(messageId, entries); this.cache.set(messageId, entries);
return entries; return entries;
} }
@@ -168,7 +168,7 @@ export class ReactionRoleService {
) { ) {
if (!this.client) return null; if (!this.client) return null;
const channel = await this.client.channels.fetch(channelId).catch(() => null); const channel = await this.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return null; if (!channel || !channel.isTextBased() || !('permissionsFor' in channel)) return null;
const perms = channel.permissionsFor(this.client.user?.id ?? ''); const perms = channel.permissionsFor(this.client.user?.id ?? '');
const canReact = perms?.has(PermissionsBitField.Flags.AddReactions); const canReact = perms?.has(PermissionsBitField.Flags.AddReactions);
if (!perms?.has(PermissionsBitField.Flags.SendMessages)) return null; if (!perms?.has(PermissionsBitField.Flags.SendMessages)) return null;

View File

@@ -9,10 +9,14 @@ export const logger = {
if (sink) sink(entry); if (sink) sink(entry);
console.log(`[INFO] ${msg}`); console.log(`[INFO] ${msg}`);
}, },
warn: (msg: string) => { warn: (msg: string, err?: unknown) => {
const entry = { level: 'WARN' as LogLevel, message: msg, timestamp: Date.now() }; const entry = { level: 'WARN' as LogLevel, message: msg, timestamp: Date.now() };
if (sink) sink(entry); if (sink) sink(entry);
if (err !== undefined) {
console.warn(`[WARN] ${msg}`, err);
} else {
console.warn(`[WARN] ${msg}`); console.warn(`[WARN] ${msg}`);
}
}, },
error: (msg: string, err?: unknown) => { error: (msg: string, err?: unknown) => {
const entry = { level: 'ERROR' as LogLevel, message: msg, timestamp: Date.now() }; const entry = { level: 'ERROR' as LogLevel, message: msg, timestamp: Date.now() };

View File

@@ -1,7 +1,7 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionResolvable, Client } from 'discord.js'; import { SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder, ChatInputCommandInteraction, PermissionResolvable, Client } from 'discord.js';
export interface SlashCommand { export interface SlashCommand {
data: SlashCommandBuilder; data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction, client: Client) => Promise<void>; execute: (interaction: ChatInputCommandInteraction, client: Client) => Promise<void>;
cooldown?: number; cooldown?: number;
requiredPermissions?: PermissionResolvable[]; requiredPermissions?: PermissionResolvable[];

View File

@@ -122,12 +122,12 @@ router.get('/guild/resources', requireAuth, async (req, res) => {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
const roles = guild.roles.cache const roles = guild.roles.cache
.filter((r) => r.name !== '@everyone') .filter((r) => r.name !== '@everyone')
.sort((a, b) => b.rawPosition - a.rawPosition)
.map((r) => ({ .map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
color: r.hexColor color: r.hexColor
})) }));
.sort((a, b) => b.rawPosition - a.rawPosition);
const categories = guild.channels.cache const categories = guild.channels.cache
.filter((c) => c.type === 4) .filter((c) => c.type === 4)
.map((c) => ({ id: c.id, name: c.name })) .map((c) => ({ id: c.id, name: c.name }))
@@ -285,7 +285,7 @@ router.get('/tickets/:id/messages', requireAuth, async (req, res) => {
// TODO: TICKETS: Live-Messages per WebSocket/Server-Sent-Events streamen statt polling, inkl. Author-Rich-Info. // TODO: TICKETS: Live-Messages per WebSocket/Server-Sent-Events streamen statt polling, inkl. Author-Rich-Info.
const msgs = await (channel as any).messages.fetch({ limit: 50 }); const msgs = await (channel as any).messages.fetch({ limit: 50 });
const data = msgs const data = msgs
.sort((a, b) => a.createdTimestamp - b.createdTimestamp) .sort((a: any, b: any) => a.createdTimestamp - b.createdTimestamp)
.map((m: any) => ({ .map((m: any) => ({
id: m.id, id: m.id,
author: { tag: m.author?.tag ?? 'Unknown', avatar: m.author?.displayAvatarURL?.() ?? null }, author: { tag: m.author?.tag ?? 'Unknown', avatar: m.author?.displayAvatarURL?.() ?? null },
@@ -305,7 +305,7 @@ router.post('/tickets/:id/close', requireAuth, async (req, res) => {
if (!ticket) return res.status(404).json({ error: 'not found' }); if (!ticket) return res.status(404).json({ error: 'not found' });
if (!context.client) return res.status(500).json({ error: 'client unavailable' }); if (!context.client) return res.status(500).json({ error: 'client unavailable' });
const channel = await context.client.channels.fetch(ticket.channelId).catch(() => null); const channel = await context.client.channels.fetch(ticket.channelId).catch(() => null);
if (channel && channel.isTextBased()) { if (channel && channel.isTextBased() && 'guild' in channel) {
const transcriptPath = await context.tickets.exportTranscript(channel as any, ticket.id); const transcriptPath = await context.tickets.exportTranscript(channel as any, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } }); await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await context.tickets['sendTranscriptToLog'](channel.guild, transcriptPath, ticket as any).catch(() => undefined); await context.tickets['sendTranscriptToLog'](channel.guild, transcriptPath, ticket as any).catch(() => undefined);
@@ -375,7 +375,15 @@ router.get('/tickets/support-login', requireAuth, async (req, res) => {
router.post('/tickets/support-login', requireAuth, async (req, res) => { router.post('/tickets/support-login', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
const config = { const config: {
panelChannelId?: string;
panelMessageId?: string;
title?: string;
description?: string;
loginLabel?: string;
logoutLabel?: string;
autoRefresh?: boolean;
} = {
panelChannelId: typeof req.body.panelChannelId === 'string' ? req.body.panelChannelId : undefined, panelChannelId: typeof req.body.panelChannelId === 'string' ? req.body.panelChannelId : undefined,
title: typeof req.body.title === 'string' ? req.body.title : undefined, title: typeof req.body.title === 'string' ? req.body.title : undefined,
description: typeof req.body.description === 'string' ? req.body.description : undefined, description: typeof req.body.description === 'string' ? req.body.description : undefined,