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 *));
|
@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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' : ''}`}>
|
||||||
{!collapsed && (
|
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">P</div>
|
||||||
<div className="min-w-0">
|
{!collapsed && (
|
||||||
<div className="text-base font-bold">Papo</div>
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-muted">Dashboard</div>
|
<div className="text-sm font-bold leading-tight">Papo</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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,38 +161,32 @@ 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
|
<AppAvatar name={user?.username} size="sm" className="shrink-0" />
|
||||||
className="w-full"
|
{!collapsed && (
|
||||||
size="sm"
|
<>
|
||||||
variant="ghost"
|
<div className="min-w-0 flex-1">
|
||||||
onPress={() => setCollapsed((c) => !c)}
|
<div className="truncate text-xs font-semibold">{user?.username}</div>
|
||||||
>
|
<div className="text-[10px] text-muted">Angemeldet</div>
|
||||||
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
</div>
|
||||||
</Button>
|
<Tooltip>
|
||||||
|
<Tooltip.Trigger>
|
||||||
<Card>
|
<Button
|
||||||
<CardContent className={`flex items-center gap-3 p-2 ${collapsed ? 'justify-center' : ''}`}>
|
isIconOnly
|
||||||
<AppAvatar name={user?.username} size="sm" className="shrink-0" />
|
size="sm"
|
||||||
{!collapsed && (
|
variant="ghost"
|
||||||
<>
|
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
<div className="flex-1 min-w-0">
|
onPress={handleLogout}
|
||||||
<div className="truncate text-xs font-semibold">{user?.username}</div>
|
>
|
||||||
<div className="text-[10px] text-muted">Angemeldet</div>
|
<LogOut size={14} />
|
||||||
</div>
|
</Button>
|
||||||
<Tooltip>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Content placement="top">Abmelden</Tooltip.Content>
|
||||||
<Button isIconOnly size="sm" variant="ghost" onPress={handleLogout}>
|
</Tooltip>
|
||||||
<LogOut size={14} />
|
</>
|
||||||
</Button>
|
)}
|
||||||
</Tooltip.Trigger>
|
</div>
|
||||||
<Tooltip.Content placement="top">Abmelden</Tooltip.Content>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 { 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>
|
||||||
</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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}` });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined);
|
if ('send' in message.channel) {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
console.warn(`[WARN] ${msg}`);
|
if (err !== undefined) {
|
||||||
|
console.warn(`[WARN] ${msg}`, err);
|
||||||
|
} else {
|
||||||
|
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() };
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user