Compare commits

..

2 Commits

Author SHA1 Message Date
Pascal Prießnitz
67643cb54d [deploy] fix register form relation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-03 18:12:36 +01:00
Pascal Prießnitz
86282fbe07 [deploy] fix register schema sortOrder 2025-12-03 18:11:44 +01:00
184 changed files with 3609 additions and 49027 deletions

View File

@@ -1,27 +0,0 @@
name: SonarQube
on:
push:
branches:
- main
- master
pull_request:
jobs:
sonar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: sonarsource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: http://10.0.0.15:9001
with:
args: >
-Dsonar.projectKey=Papo
-Dsonar.projectName=Papo
-Dsonar.sources=.

2
.gitignore vendored
View File

@@ -1,4 +1,2 @@
.env
node_modules
debug.log
dist/

View File

@@ -25,9 +25,9 @@ else
fi
echo "[DEPLOY] Starte docker compose..."
docker-compose pull || true
docker-compose build
docker-compose up -d
docker compose pull || true
docker compose build
docker compose up -d
echo "[DEPLOY] Aufräumen..."
docker image prune -f || true

View File

@@ -4,12 +4,12 @@ services:
app:
build:
context: .
dockerfile: dockerfile
dockerfile: Dockerfile
image: papo-app:latest
working_dir: /usr/src/app
env_file:
- .env
command: sh -c "npx prisma migrate deploy --schema=src/database/schema.prisma && npm run dev"
command: sh -c "npm run dev"
ports:
- "3000:3000"
depends_on:

View File

@@ -12,30 +12,20 @@ ENV PATH="/usr/src/app/node_modules/.bin:${PATH}"
ENV DATABASE_URL=postgresql://user:pass@localhost:5432/papo?schema=public
ENV PRISMA_IGNORE_ENV_LOAD=true
# Install backend dependencies
# Install dependencies (inkl. dev)
COPY package*.json ./
RUN npm ci --include=dev
# Install frontend dependencies
COPY frontend/package*.json ./frontend/
RUN npm --prefix frontend ci
# Copy source
COPY . .
# Build frontend
RUN npm run build:web
# Ensure prisma CLI available globally (avoids path issues)
RUN npm install -g prisma@5.4.2
# Copy source
COPY . .
# Generate Prisma client (explicit schema path)
RUN prisma generate --schema=src/database/schema.prisma
# Build backend (tsc emits JS even with type errors; exit code suppressed for pre-existing errors)
RUN npm run build:web && npx tsc || true
# Optional: show versions in build log
RUN node -v && npm -v && npx prisma -v
CMD ["npm", "start"]
CMD ["npm", "run", "dev"]

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Papo Dashboard</title>
<script>__PAPO_CONFIG__</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "papo-dashboard-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^3.2.1",
"@heroui/styles": "^3.2.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.542.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"vite": "^7.1.3"
}
}

View File

@@ -1,88 +0,0 @@
import { useEffect } from 'react';
import { useApp } from './context/AppContext';
import { AppLayout } from './components/layout/AppLayout';
import { GuildSelect } from './pages/GuildSelect';
import { Dashboard } from './pages/Dashboard';
import { Tickets } from './pages/Tickets';
import { SupportLogin } from './pages/SupportLogin';
import { Automod } from './pages/Automod';
import { Welcome } from './pages/Welcome';
import { DynamicVoice } from './pages/DynamicVoice';
import { Birthday } from './pages/Birthday';
import { ReactionRoles } from './pages/ReactionRoles';
import { Statuspage } from './pages/Statuspage';
import { ServerStats } from './pages/ServerStats';
import { Register } from './pages/Register';
import { MusicPage } from './pages/Music';
import { SettingsPage } from './pages/Settings';
import { ModulesPage } from './pages/Modules';
import { Events } from './pages/Events';
import { Tasks } from './pages/Tasks';
import { Watchlist } from './pages/Watchlist';
import { Admin } from './pages/Admin';
import { Branding } from './pages/Branding';
import { Growth } from './pages/Growth';
import { Permissions } from './pages/Permissions';
import { Panels } from './pages/Panels';
const THEME_COLORS: Record<string, string> = {
orange: '#f97316',
blue: '#3b82f6',
green: '#22c55e',
purple: '#a855f7',
red: '#ef4444'
};
function AppContent() {
const { guilds, currentGuildId, section, settings } = useApp();
useEffect(() => {
const branding = settings.brandingConfig || {};
const color = branding.embedColor || (branding.theme ? THEME_COLORS[branding.theme] : undefined);
if (color) document.documentElement.style.setProperty('--accent', color);
}, [settings.brandingConfig]);
if (!guilds.length) {
return <GuildSelect />;
}
if (!currentGuildId) {
return <GuildSelect />;
}
switch (section) {
case 'overview': return <Dashboard />;
case 'tickets': return <Tickets />;
case 'supportlogin': return <SupportLogin />;
case 'automod': return <Automod />;
case 'welcome': return <Welcome />;
case 'dynamicvoice': return <DynamicVoice />;
case 'birthday': return <Birthday />;
case 'reactionroles': return <ReactionRoles />;
case 'statuspage': return <Statuspage />;
case 'serverstats': return <ServerStats />;
case 'register': return <Register />;
case 'music': return <MusicPage />;
case 'settings': return <SettingsPage />;
case 'modules': return <ModulesPage />;
case 'events': return <Events />;
case 'tasks': return <Tasks />;
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'permissions': return <Permissions />;
case 'panels': return <Panels />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}
}
function App() {
return (
<AppLayout>
<AppContent />
</AppLayout>
);
}
export default App;

View File

@@ -1,85 +0,0 @@
@import "tailwindcss";
@import "@heroui/styles";
@custom-variant dark (&:is(.dark *));
:root,
.light,
.dark {
/* Brand accent: matches the orange used across bot embeds and the login page
(see 0xf97316 in src/services/*.ts and src/web/server.ts), overriding
HeroUI's blue default theme token. */
--accent: #f97316;
--accent-foreground: var(--snow);
}
:root {
/* HeroUI ships fields with a transparent border and 0 border-width by default,
so an Input on a plain Card is invisible (identical bg, no outline). Give
every field a real, visible box across the whole app. */
--field-border-width: 1px;
--field-border: var(--border-secondary);
--field-background: var(--surface-tertiary);
}
html, body, #root {
min-height: 100%;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--background);
color: var(--foreground);
}
* {
box-sizing: border-box;
}
button {
cursor: pointer;
}
select {
width: 100%;
border: var(--border-width-field) solid var(--field-border, var(--border));
border-radius: var(--radius-field);
background: var(--field-background, var(--default));
color: var(--field-foreground, var(--foreground));
font: inherit;
padding: 0.6rem 0.85rem;
transition: border-color 150ms ease, background-color 150ms ease;
}
select:hover {
background: var(--field-hover);
}
select:focus {
outline: none;
border-color: var(--field-border-focus);
background: var(--field-focus);
}
label {
display: inline-block;
margin-bottom: 0.35rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}

View File

@@ -1,40 +0,0 @@
import { Spinner } from '@heroui/react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { useApp } from '../../context/AppContext';
export function AppLayout({ children }: { children: React.ReactNode }) {
const { loading, guilds } = useApp();
if (loading) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3">
<Spinner color="accent" size="lg" />
<p className="text-sm text-muted">Dashboard wird geladen...</p>
</div>
);
}
if (!guilds.length) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted">Keine Server verfügbar</p>
</div>
);
}
return (
<div className="flex h-screen overflow-hidden">
<div className="hidden shrink-0 lg:block">
<Sidebar />
</div>
<main className="flex flex-1 flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-[1520px] px-6 py-6">
<Header />
{children}
</div>
</main>
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { Button, Chip, Tooltip, Dropdown, DropdownTrigger, DropdownPopover, DropdownMenu, DropdownItem } from '@heroui/react';
import { Moon, Sun, ChevronDown, LogOut, Settings } from 'lucide-react';
import { useTheme } from '../../hooks/useTheme';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
const navLabels: Record<string, string> = {
overview: 'Übersicht',
tickets: 'Ticketsystem',
supportlogin: 'Support Login',
automod: 'Automod',
welcome: 'Willkommen',
dynamicvoice: 'Dynamic Voice',
birthday: 'Birthday',
reactionroles: 'Reaction Roles',
statuspage: 'Statuspage',
serverstats: 'Server Stats',
register: 'Registrierung',
music: 'Musik',
settings: 'Einstellungen',
modules: 'Module',
events: 'Events',
admin: 'Admin',
};
export function Header() {
const { dark, toggle } = useTheme();
const { guildName, statusMessage } = useHeaderData();
const { user, handleLogout, setSection, section } = useApp();
return (
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-1.5 text-sm text-muted min-w-0">
<button className="hover:text-foreground transition-colors" onClick={() => setSection('overview')}>
Dashboard
</button>
{section !== 'overview' && (
<>
<span>/</span>
<span className="text-accent font-semibold truncate">{navLabels[section] || section}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{statusMessage && (
<Chip size="sm" variant="soft" color="accent" className="max-w-[220px]">
<span className="truncate">{statusMessage}</span>
</Chip>
)}
<Tooltip>
<Tooltip.Trigger>
<Button isIconOnly size="sm" variant="ghost" onPress={toggle}>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="bottom">{dark ? 'Helles Design' : 'Dunkles Design'}</Tooltip.Content>
</Tooltip>
<Dropdown>
<DropdownTrigger className="inline-flex h-9 items-center gap-2 rounded-3xl px-3 text-sm font-medium hover:bg-default-hover">
<AppAvatar name={user?.username} size="sm" className="size-6" />
<span className="hidden sm:inline text-sm">{user?.username}</span>
<ChevronDown size={14} />
</DropdownTrigger>
<DropdownPopover placement="bottom end">
<DropdownMenu aria-label="User Menu">
<DropdownItem key="settings" onAction={() => setSection('settings')}>
<Settings size={14} /> Einstellungen
</DropdownItem>
<DropdownItem key="logout" variant="danger" onAction={handleLogout}>
<LogOut size={14} /> Abmelden
</DropdownItem>
</DropdownMenu>
</DropdownPopover>
</Dropdown>
</div>
</div>
);
}
function useHeaderData() {
const { guildInfo, guilds, currentGuildId, statusMessage } = useApp();
const selectedGuild = guilds.find((g) => g.id === currentGuildId);
return {
guildName: guildInfo?.name || selectedGuild?.name || 'Dashboard',
statusMessage,
};
}

View File

@@ -1,212 +0,0 @@
import { useState } from 'react';
import {
Button, ScrollShadow, Tooltip,
Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, ShieldAlert, Sparkles, Tag, Ticket, TrendingUp, LayoutPanelTop, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
const navGroups = [
{
label: 'Dashboard',
items: [
{ key: 'overview', label: 'Uebersicht', icon: <Home size={18} /> },
]
},
{
label: 'Community',
items: [
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={18} /> },
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'panels', label: 'Info-Panels', icon: <LayoutPanelTop size={18} /> },
]
},
{
label: 'Support',
items: [
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={18} /> },
{ key: 'supportlogin', label: 'Support Login', icon: <LogIn size={18} /> },
{ key: 'register', label: 'Registrierung', icon: <ClipboardList size={18} /> },
]
},
{
label: 'Moderation',
items: [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'permissions', label: 'Rechte-Scanner', icon: <ShieldAlert size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
]
},
{
label: 'Funktionen',
items: [
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={18} /> },
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
]
},
{
label: 'Statistiken',
items: [
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },
]
},
{
label: 'System',
items: [
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'branding', label: 'Branding', icon: <Palette size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
]
}
];
export function Sidebar() {
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout, settings } = useApp();
const [collapsed, setCollapsed] = useState(false);
const branding = settings.brandingConfig || {};
const botName = branding.botName || 'Papo';
return (
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
{branding.logoUrl ? (
<img src={branding.logoUrl} alt={botName} className="size-8 shrink-0 rounded-xl object-cover" />
) : (
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">
{botName.trim()[0]?.toUpperCase() || 'P'}
</div>
)}
{!collapsed && (
<div className="min-w-0">
<div className="truncate text-sm font-bold leading-tight">{botName}</div>
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
</div>
)}
</div>
<Button isIconOnly size="sm" variant="ghost" className="shrink-0" onPress={() => setCollapsed((c) => !c)}>
{collapsed ? <PanelLeft size={15} /> : <PanelLeftClose size={15} />}
</Button>
</div>
<div className="px-2 pb-2">
<Select
aria-label="Guild auswaehlen"
selectedKey={currentGuildId}
onSelectionChange={(key) => {
if (typeof key === 'string') setCurrentGuildId(key);
}}
>
<SelectTrigger className="w-full">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectPopover>
<ListBox>
{guilds.map((g) => (
<ListBoxItem key={g.id} id={g.id} textValue={g.name}>
{collapsed ? g.name.slice(0, 2) : g.name}
</ListBoxItem>
))}
</ListBox>
</SelectPopover>
</Select>
</div>
<ScrollShadow className="flex-1 px-2 py-2" hideScrollBar>
<nav className="flex flex-col gap-3">
{navGroups.map((group) => (
<div key={group.label}>
{!collapsed && (
<div className="px-2 pb-1 text-[9px] font-semibold uppercase tracking-[0.16em] text-muted">
{group.label}
</div>
)}
<div className="flex flex-col gap-0.5">
{group.items
.filter((item) => item.key !== 'admin' || user?.isAdmin)
.map((item) => {
const isActive = section === item.key;
return (
<Tooltip key={item.key} isDisabled={!collapsed}>
<Tooltip.Trigger>
<Button
className={`h-9 justify-start gap-2.5 px-2.5 text-sm font-medium ${isActive ? 'bg-accent-soft text-accent-soft-foreground' : 'text-muted'}`}
variant="ghost"
size="sm"
onPress={() => setSection(item.key as any)}
>
{item.icon} {!collapsed && <span className="truncate">{item.label}</span>}
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="right" offset={8}>{item.label}</Tooltip.Content>
</Tooltip>
);
})}
</div>
</div>
))}
{user?.isAdmin && (
<div>
{!collapsed && (
<div className="px-2 pb-1 text-[9px] font-semibold uppercase tracking-[0.16em] text-muted">
Admin
</div>
)}
<Tooltip isDisabled={!collapsed}>
<Tooltip.Trigger>
<Button
className={`h-9 justify-start gap-2.5 px-2.5 text-sm font-medium ${section === 'admin' ? 'bg-accent-soft text-accent-soft-foreground' : 'text-muted'}`}
variant="ghost"
size="sm"
onPress={() => setSection('admin' as any)}
>
<Wrench size={18} /> {!collapsed && 'Admin'}
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="right" offset={8}>Admin</Tooltip.Content>
</Tooltip>
</div>
)}
</nav>
</ScrollShadow>
<div className="border-t border-border p-2">
<div className={`group flex items-center gap-2 rounded-lg p-1.5 transition-colors hover:bg-surface-secondary ${collapsed ? 'justify-center' : ''}`}>
<AppAvatar name={user?.username} size="sm" className="shrink-0" />
{!collapsed && (
<>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{user?.username}</div>
<div className="text-[10px] text-muted">Angemeldet</div>
</div>
<Tooltip>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onPress={handleLogout}
>
<LogOut size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content placement="top">Abmelden</Tooltip.Content>
</Tooltip>
</>
)}
</div>
</div>
</aside>
);
}

View File

@@ -1,36 +0,0 @@
import { Button, Chip } from '@heroui/react';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
type Props = {
guildName: string;
statusMessage?: string;
children?: React.ReactNode;
};
export function Topbar({ guildName, statusMessage, children }: Props) {
const [dark, setDark] = useState(() => localStorage.getItem('papo-theme') !== 'light');
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('papo-theme', dark ? 'dark' : 'light');
}, [dark]);
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{guildName}</h1>
<p className="mt-1 text-sm text-muted">Bot-Dashboard Verwaltung</p>
</div>
<div className="flex items-center gap-3">
{statusMessage && (
<Chip color="warning" size="sm" variant="soft">{statusMessage}</Chip>
)}
<Button isIconOnly size="sm" variant="ghost" onPress={() => setDark((d) => !d)}>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
{children}
</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { Card, CardContent, Chip } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
icon: ReactNode;
label: string;
value: number;
};
export function ActivityTile({ icon, label, value }: Props) {
return (
<Card>
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
<div className="flex items-center gap-3">
<Chip color="accent" size="sm" variant="soft">
{icon} {label}
</Chip>
<div>
<div className="text-2xl font-black">{value}</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,17 +0,0 @@
import { Avatar } from '@heroui/react';
type Props = {
src?: string;
name?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
};
export function AppAvatar({ src, name, size = 'md', className }: Props) {
return (
<Avatar size={size} className={className}>
{src && <Avatar.Image src={src} alt={name || 'Avatar'} />}
<Avatar.Fallback>{name?.trim()?.[0]?.toUpperCase() || '?'}</Avatar.Fallback>
</Avatar>
);
}

View File

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

View File

@@ -1,29 +0,0 @@
type Item = {
label: string;
value: number;
};
type Props = {
items: Item[];
};
export function BarComparisonChart({ items }: Props) {
const max = Math.max(1, ...items.map((i) => i.value));
return (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-3">
<span className="w-24 shrink-0 truncate text-xs text-muted">{item.label}</span>
<div className="h-2 flex-1 rounded-full bg-default">
<div
className="h-2 rounded-r-full bg-accent"
style={{ width: `${Math.max(2, (item.value / max) * 100)}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-xs font-semibold tabular-nums">{item.value}</span>
</div>
))}
</div>
);
}

View File

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

View File

@@ -1,61 +0,0 @@
import type { ReactNode } from 'react';
import { Bot } from 'lucide-react';
type Props = {
botName?: string;
title?: string;
description?: string;
footer?: string;
image?: 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, image, 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 || image) && (
<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>}
{image && <img src={image} alt="" className="mt-2.5 max-h-64 w-full max-w-full rounded object-cover" />}
</div>
)}
{children && <div className="mt-2 flex flex-wrap gap-2">{children}</div>}
</div>
</div>
</div>
);
}
export function DiscordButton({ variant = 'primary', children }: { variant?: 'primary' | 'secondary'; children: ReactNode }) {
return (
<div
className={`flex h-8 items-center gap-1.5 rounded px-3 text-sm font-medium text-white ${
variant === 'primary' ? 'bg-[#5865f2]' : 'bg-[#4e5058]'
}`}
>
{children}
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { Card, CardContent } from '@heroui/react';
import { Inbox } from 'lucide-react';
type Props = {
message?: string;
icon?: React.ReactNode;
};
export function EmptyState({ message = 'Keine Daten vorhanden', icon }: Props) {
return (
<Card className="bg-surface-tertiary">
<CardContent className="flex flex-col items-center gap-3 py-8">
<div className="flex size-12 items-center justify-center rounded-full bg-default-soft text-muted">
{icon || <Inbox size={24} />}
</div>
<p className="text-sm text-muted">{message}</p>
</CardContent>
</Card>
);
}

View File

@@ -1,25 +0,0 @@
import { Card, CardContent } from '@heroui/react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
type Props = {
message?: string;
onRetry?: () => void;
};
export function ErrorState({ message = 'Ein Fehler ist aufgetreten', onRetry }: Props) {
return (
<Card className="border border-danger-soft bg-danger-soft/40">
<CardContent className="flex flex-col items-center gap-3 py-8">
<div className="flex size-12 items-center justify-center rounded-full bg-danger-soft text-danger">
<AlertTriangle size={24} />
</div>
<p className="text-sm text-muted">{message}</p>
{onRetry && (
<button className="flex items-center gap-1 text-xs text-accent hover:opacity-80 transition-opacity" onClick={onRetry}>
<RefreshCw size={12} /> Erneut versuchen
</button>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,18 +0,0 @@
import { Card, CardHeader, CardContent, CardTitle } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
children: ReactNode;
};
export function FormPanel({ title, children }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{children}</CardContent>
</Card>
);
}

View File

@@ -1,21 +0,0 @@
import { Card, CardHeader, CardContent, CardTitle } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
children: ReactNode;
className?: string;
};
export function ListPanel({ title, children, className }: Props) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{children}
</CardContent>
</Card>
);
}

View File

@@ -1,20 +0,0 @@
import { Card, CardContent, Skeleton } from '@heroui/react';
type Props = {
lines?: number;
};
export function LoadingSkeleton({ lines = 3 }: Props) {
return (
<div className="flex flex-col gap-3">
{Array.from({ length: lines }).map((_, i) => (
<Card key={i}>
<CardContent className="gap-2 p-5">
<Skeleton className="h-4 w-3/4 rounded-lg" />
{i < 2 && <Skeleton className="mt-3 h-3 w-1/2 rounded-lg" />}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from 'react';
import { AppSwitch } from './AppSwitch';
type Props = {
icon: ReactNode;
title: string;
description: string;
isSelected: boolean;
onChange: (value: boolean) => void;
};
export function ModuleActiveToggle({ icon, title, description, isSelected, onChange }: Props) {
return (
<div className="flex items-center gap-3 rounded-xl border border-accent bg-accent-soft p-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{title}</div>
<div className="text-xs text-muted">{description}</div>
</div>
<AppSwitch aria-label={title} isSelected={isSelected} onChange={onChange} />
</div>
);
}

View File

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

View File

@@ -1,24 +0,0 @@
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
subtitle?: string;
children: ReactNode;
action?: ReactNode;
};
export function SectionCard({ title, subtitle, children, action }: Props) {
return (
<Card className="bg-surface-secondary">
<CardHeader className="flex items-start justify-between gap-4">
<div className="min-w-0 flex flex-col gap-1">
<CardTitle>{title}</CardTitle>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
{action && <div className="shrink-0">{action}</div>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}

View File

@@ -1,39 +0,0 @@
import { Card, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
icon: ReactNode;
label: string;
value: string | number;
trend?: string;
color?: 'accent' | 'success' | 'warning' | 'danger' | 'default';
};
const iconClasses: Record<NonNullable<Props['color']>, string> = {
accent: 'bg-accent-soft text-accent-soft-foreground',
success: 'bg-success-soft text-success-soft-foreground',
warning: 'bg-warning-soft text-warning-soft-foreground',
danger: 'bg-danger-soft text-danger-soft-foreground',
default: 'bg-default-soft text-muted',
};
export function StatCard({ icon, label, value, trend, color = 'default' }: Props) {
return (
<Card>
<CardContent className="flex flex-row items-center gap-3 p-3">
<div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${iconClasses[color]}`}>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-muted">{label}</div>
<div className="text-lg font-bold leading-tight tracking-tight">{value}</div>
</div>
{trend && (
<span className={`shrink-0 text-xs font-medium ${trend.startsWith('+') ? 'text-success' : trend.startsWith('-') ? 'text-danger' : 'text-muted'}`}>
{trend}
</span>
)}
</CardContent>
</Card>
);
}

View File

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

View File

@@ -1,745 +0,0 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { apiFetch } from '../utils/api';
import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType,
TicketTopicConfig
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
type AppState = {
user: User | null;
guilds: Guild[];
currentGuildId: string;
section: NavKey;
guildInfo: any;
overview: any;
activity: any;
logs: LogEntry[];
tickets: TicketRecord[];
pipeline: Record<string, TicketRecord[]>;
sla: any;
automations: any[];
kbArticles: any[];
settings: SettingsState;
modules: ModuleItem[];
birthday: any;
reactionRoles: ReactionRoleSet[];
statuspage: any;
serverStats: any;
events: EventItem[];
admin: any;
statusMessage: string;
loading: boolean;
supportLogin: { config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null;
registerForms: RegisterForm[];
registerApps: RegisterApplication[];
musicStatus: { activeGuilds: number; sessions: MusicSession[] };
automodStrikes: { userId: string; count: number; lastAt: string; reasons: string[] }[];
registerStatusFilter: string;
registerFormFilter: string;
selectedAppId: string | null;
appNotes: any[];
appHistory: any[];
noteDraft: string;
tasks: StaffTask[];
taskDraft: { title: string; description: string };
watchlistEntries: WatchlistEntry[];
growthStats: GrowthStats | null;
inviteBreakdown: InviteBreakdownEntry[];
recentJoins: RecentJoinEntry[];
permissionScan: PermissionScanResult | null;
permissionScanLoading: boolean;
infoPanels: InfoPanel[];
panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string };
ticketTopics: Record<string, TicketTopicConfig>;
ticketTopicDraft: { topic: string; roleId: string; questions: string };
};
type AppContextType = AppState & {
setCurrentGuildId: (id: string) => void;
setSection: (key: NavKey) => void;
setSettings: (s: SettingsState | ((prev: SettingsState) => SettingsState)) => void;
setBirthday: (s: any | ((prev: any) => any)) => void;
setSupportLogin: (s: any | ((prev: any) => any)) => void;
setStatusDraft: (s: any | ((prev: any) => any)) => void;
setStatsDraft: (s: any | ((prev: any) => any)) => void;
setStatusMessage: (msg: string) => void;
loadGuildData: (guildId: string) => Promise<void>;
saveSettingsPayload: (payload: Record<string, any>, okMessage: string) => Promise<void>;
saveBirthday: () => Promise<void>;
saveStatuspage: () => Promise<void>;
saveServerStats: () => Promise<void>;
toggleModule: (key: string, enabled: boolean) => Promise<void>;
handleLogout: () => void;
loadTicketData: (guildId: string) => Promise<void>;
loadTicketMessages: (ticketId: string) => Promise<void>;
updateTicketStatus: (ticketId: string, status: string) => Promise<void>;
closeTicket: (ticketId: string) => Promise<void>;
saveAutomation: () => Promise<void>;
saveKbArticle: () => Promise<void>;
updateKbArticle: (id: string) => Promise<void>;
deleteKbArticle: (id: string) => Promise<void>;
updateAutomation: (id: string) => Promise<void>;
deleteAutomation: (id: string) => Promise<void>;
saveSupportLogin: () => Promise<void>;
saveForm: () => Promise<void>;
deleteForm: (id: string) => Promise<void>;
sendFormPanel: (formId: string) => Promise<void>;
addStatusService: () => Promise<void>;
deleteStatusService: (id: string) => Promise<void>;
addStatsItem: () => Promise<void>;
deleteStatsItem: (index: number) => Promise<void>;
saveEvent: () => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
saveReactionRole: () => Promise<void>;
ticketTab: string;
setTicketTab: (tab: string) => void;
automationDraft: any;
setAutomationDraft: (s: any | ((prev: any) => any)) => void;
kbDraft: any;
setKbDraft: (s: any | ((prev: any) => any)) => void;
eventDraft: any;
setEventDraft: (s: any | ((prev: any) => any)) => void;
statusDraft: any;
statsDraft: any;
reactionDraft: any;
setReactionDraft: (s: any | ((prev: any) => any)) => void;
formDraft: any;
setFormDraft: (s: any | ((prev: any) => any)) => void;
editingFormId: string | null;
setEditingFormId: (id: string | null) => void;
registerTab: string;
setRegisterTab: (tab: string) => void;
statusServiceDraft: any;
setStatusServiceDraft: (s: any | ((prev: any) => any)) => void;
statsItemDraft: any;
setStatsItemDraft: (s: any | ((prev: any) => any)) => void;
ticketDetail: TicketRecord | null;
setTicketDetail: (t: TicketRecord | null) => void;
ticketMessages: any[];
kbEditDraft: any;
setKbEditDraft: (s: any | ((prev: any) => any)) => void;
automationEditDraft: any;
setAutomationEditDraft: (s: any | ((prev: any) => any)) => void;
loadAutomodStrikes: () => Promise<void>;
resetAutomodStrike: (userId: string) => Promise<void>;
setRegisterStatusFilter: (v: string) => void;
setRegisterFormFilter: (v: string) => void;
loadRegisterApps: (overrides?: { status?: string; formId?: string }) => Promise<void>;
openAppDetail: (id: string) => Promise<void>;
setNoteDraft: (v: string) => void;
addAppNote: () => Promise<void>;
setTaskDraft: (s: any | ((prev: any) => any)) => void;
createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
loadWatchlist: () => Promise<void>;
removeFromWatchlist: (userId: string) => Promise<void>;
loadGrowthStats: () => Promise<void>;
loadInviteBreakdown: () => Promise<void>;
loadPermissionScan: () => Promise<void>;
loadInfoPanels: () => Promise<void>;
setPanelDraft: (s: any | ((prev: any) => any)) => void;
createInfoPanel: () => Promise<void>;
deleteInfoPanel: (id: string) => Promise<void>;
loadTicketTopics: () => Promise<void>;
setTicketTopicDraft: (s: any | ((prev: any) => any)) => void;
saveTicketTopic: () => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [guilds, setGuilds] = useState<Guild[]>([]);
const [currentGuildId, setCurrentGuildId] = useState(appConfig.initialGuildId || '');
const [section, setSectionState] = useState<NavKey>('overview');
const [guildInfo, setGuildInfo] = useState<any>(null);
const [overview, setOverview] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [pipeline, setPipeline] = useState<Record<string, TicketRecord[]>>({});
const [sla, setSla] = useState<any>({ supporters: [], days: [] });
const [automations, setAutomations] = useState<any[]>([]);
const [kbArticles, setKbArticles] = useState<any[]>([]);
const [settings, setSettings] = useState<SettingsState>({});
const [modules, setModules] = useState<ModuleItem[]>([]);
const [birthday, setBirthday] = useState<any>({ config: {}, birthdays: [] });
const [reactionRoles, setReactionRoles] = useState<ReactionRoleSet[]>([]);
const [statuspage, setStatuspage] = useState<any>({ services: [] });
const [serverStats, setServerStats] = useState<any>({ items: [] });
const [events, setEvents] = useState<EventItem[]>([]);
const [admin, setAdmin] = useState<any>({ overview: null, activity: null, logs: [] });
const [statusMessage, setStatusMessage] = useState('');
const [ticketTab, setTicketTab] = useState('overview');
const [automationDraft, setAutomationDraft] = useState({ name: '', conditionValue: '', actionValue: '' });
const [kbDraft, setKbDraft] = useState({ title: '', keywords: '', content: '' });
const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' });
const [statusDraft, setStatusDraft] = useState<any>(null);
const [statsDraft, setStatsDraft] = useState<any>(null);
const [reactionDraft, setReactionDraft] = useState<{ title: string; channelId: string; entries: { emoji: string; roleId: string; label: string; description: string }[] }>({ title: '', channelId: '', entries: [] });
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
const [formDraft, setFormDraft] = useState({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
const [editingFormId, setEditingFormId] = useState<string | null>(null);
const [registerTab, setRegisterTab] = useState('forms');
const [musicStatus, setMusicStatus] = useState<{ activeGuilds: number; sessions: MusicSession[] }>({ activeGuilds: 0, sessions: [] });
const [kbEditDraft, setKbEditDraft] = useState<{ id: string; title: string; keywords: string; content: string } | null>(null);
const [automationEditDraft, setAutomationEditDraft] = useState<{ id: string; name: string; conditionValue: string; actionValue: string } | null>(null);
const [statusServiceDraft, setStatusServiceDraft] = useState<{ id?: string; name: string; url: string; status: string }>({ name: '', url: '', status: 'unknown' });
const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' });
const [ticketDetail, setTicketDetail] = useState<TicketRecord | null>(null);
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
const [automodStrikes, setAutomodStrikes] = useState<{ userId: string; count: number; lastAt: string; reasons: string[] }[]>([]);
const [registerStatusFilter, setRegisterStatusFilter] = useState('');
const [registerFormFilter, setRegisterFormFilter] = useState('');
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [appNotes, setAppNotes] = useState<any[]>([]);
const [appHistory, setAppHistory] = useState<any[]>([]);
const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
const [permissionScan, setPermissionScan] = useState<PermissionScanResult | null>(null);
const [permissionScanLoading, setPermissionScanLoading] = useState(false);
const [infoPanels, setInfoPanels] = useState<InfoPanel[]>([]);
const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' });
const [ticketTopics, setTicketTopics] = useState<Record<string, TicketTopicConfig>>({});
const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' });
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
window.location.hash = key;
}, []);
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'tasks', 'watchlist', 'branding', 'growth', 'permissions', 'panels', 'admin'];
if (validKeys.includes(hash)) setSectionState(hash);
}, []);
useEffect(() => {
if (currentGuildId) loadGuildData(currentGuildId);
}, [currentGuildId]);
useEffect(() => { bootstrap(); }, []);
async function bootstrap() {
try {
const me = await apiFetch<{ user: User }>('/me');
const guildRes = await apiFetch<{ guilds: Guild[] }>('/guilds');
setUser(me.user);
setGuilds(guildRes.guilds || []);
if (!currentGuildId && guildRes.guilds?.length) setCurrentGuildId(guildRes.guilds[0].id);
} finally { setLoading(false); }
}
async function loadTicketData(guildId: string) {
try {
const [ticketRes, pipelineRes, slaRes, automationRes, kbRes] = await Promise.all([
apiFetch<any>(`/tickets?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/pipeline?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/sla?guildId=${encodeURIComponent(guildId)}&range=30`),
apiFetch<any>(`/automations?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/kb?guildId=${encodeURIComponent(guildId)}`)
]);
setTickets(ticketRes.tickets || []);
setPipeline(pipelineRes.pipeline || {});
setSla(slaRes || { supporters: [], days: [] });
setAutomations(automationRes.rules || []);
setKbArticles(kbRes.articles || []);
} catch {}
}
async function loadGuildData(guildId: string) {
setStatusMessage('Lade Daten...');
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes, tasksRes, watchlistRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/logs?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/settings?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/modules?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/birthday?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/server-stats?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
setMusicStatus(overviewRes.music || { activeGuilds: 0, sessions: [] });
setActivity(activityRes.activity || {});
setLogs(logsRes.logs || []);
setSettings(settingsRes.settings || {});
setModules(modulesRes.modules || []);
setBirthday(birthdayRes);
setReactionRoles(reactionRes.sets || []);
setStatuspage(statusRes.config || { services: [] });
setServerStats(statsRes.config || { items: [] });
setStatsDraft(statsRes.config || { items: [] });
setStatusDraft(statusRes.config || { services: [] });
setEvents(eventsRes.events || []);
setSupportLogin(supportLoginRes);
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setWatchlistEntries(watchlistRes.entries || []);
setReactionDraft({ title: '', channelId: '', entries: [] });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
} catch { setStatusMessage('Daten konnten nicht geladen werden'); }
}
async function loadAdminData() {
if (!user?.isAdmin) return;
try {
const [overviewRes, activityRes, logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({
overview: overviewRes.overview || {},
activity: activityRes.points || [],
logs: logsRes.logs || [],
guildList: overviewRes.guildList || [],
usage: overviewRes.usage || {}
});
} catch {}
}
async function saveSettingsPayload(payload: Record<string, any>, okMessage: string) {
if (!currentGuildId) return;
await apiFetch('/settings', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, ...payload }) });
setStatusMessage(okMessage);
await loadGuildData(currentGuildId);
}
async function saveBirthday() {
await apiFetch('/birthday', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
enabled: birthday.config?.enabled ?? true,
channelId: birthday.config?.channelId || '',
sendHour: birthday.config?.sendHour || 9,
messageTemplate: birthday.config?.messageTemplate || ''
})
});
setStatusMessage('Birthday gespeichert');
await loadGuildData(currentGuildId);
}
async function saveStatuspage() {
await apiFetch('/statuspage', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: statusDraft }) });
setStatusMessage('Statuspage gespeichert');
await loadGuildData(currentGuildId);
}
async function saveServerStats() {
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: statsDraft }) });
setStatusMessage('Server Stats gespeichert');
await loadGuildData(currentGuildId);
}
async function saveEvent() {
if (!eventDraft.title) return;
await apiFetch('/events', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId, title: eventDraft.title,
description: eventDraft.description, channelId: eventDraft.channelId || undefined,
startsAt: eventDraft.startsAt || undefined
})
});
setEventDraft({ title: '', description: '', channelId: '', startsAt: '' });
await loadGuildData(currentGuildId);
setStatusMessage('Event gespeichert');
}
async function deleteEvent(id: string) {
await apiFetch(`/events/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
await loadGuildData(currentGuildId);
}
async function saveReactionRole() {
const entries = (reactionDraft.entries as any[]).filter((e) => e.emoji && e.roleId);
await apiFetch('/reactionroles', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: reactionDraft.channelId, title: reactionDraft.title, entries }) });
await loadGuildData(currentGuildId);
setReactionDraft({ title: '', channelId: '', entries: [] });
setStatusMessage('Reaction Role gespeichert');
}
async function toggleModule(key: string, enabled: boolean) {
await saveSettingsPayload({ [key]: enabled }, `${key} aktualisiert`);
}
async function saveAutomation() {
await apiFetch('/automations', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId, name: automationDraft.name || 'Automation',
condition: { category: automationDraft.conditionValue },
action: { type: 'reminder', message: automationDraft.actionValue || 'Reminder' }, active: true
})
});
setAutomationDraft({ name: '', conditionValue: '', actionValue: '' });
await loadTicketData(currentGuildId);
}
async function saveKbArticle() {
await apiFetch('/kb', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, title: kbDraft.title || 'Artikel', keywords: kbDraft.keywords, content: kbDraft.content })
});
setKbDraft({ title: '', keywords: '', content: '' });
await loadTicketData(currentGuildId);
}
async function updateKbArticle(id: string) {
if (!kbEditDraft) return;
await apiFetch(`/kb/${id}`, {
method: 'PUT',
body: JSON.stringify({ guildId: currentGuildId, title: kbEditDraft.title, keywords: kbEditDraft.keywords, content: kbEditDraft.content })
});
setKbEditDraft(null);
setStatusMessage('KB-Artikel aktualisiert');
await loadTicketData(currentGuildId);
}
async function deleteKbArticle(id: string) {
await apiFetch(`/kb/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('KB-Artikel gelöscht');
await loadTicketData(currentGuildId);
}
async function updateAutomation(id: string) {
if (!automationEditDraft) return;
await apiFetch(`/automations/${id}`, {
method: 'PUT',
body: JSON.stringify({ guildId: currentGuildId, name: automationEditDraft.name, condition: { category: automationEditDraft.conditionValue }, action: { type: 'reminder', message: automationEditDraft.actionValue }, active: true })
});
setAutomationEditDraft(null);
setStatusMessage('Automation aktualisiert');
await loadTicketData(currentGuildId);
}
async function deleteAutomation(id: string) {
await apiFetch(`/automations/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Automation gelöscht');
await loadTicketData(currentGuildId);
}
async function saveSupportLogin() {
if (!supportLogin) return;
await apiFetch('/tickets/support-login', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, ...supportLogin.config })
});
setStatusMessage('Support Login gespeichert');
await loadGuildData(currentGuildId);
}
async function saveForm() {
const fields = formDraft.fields.split('\n').filter(Boolean).map((line) => {
const parts = line.split('|').map((s) => s.trim());
return { label: parts[0] || 'Feld', type: (parts[1] || 'text') as any, required: parts[2] === 'required', options: parts[3] ? parts[3].split(',').map((s) => s.trim()) : undefined };
});
const body: any = { guildId: currentGuildId, name: formDraft.name, description: formDraft.description, reviewChannelId: formDraft.reviewChannelId || undefined, notifyRoleIds: formDraft.notifyRoleIds.split(',').map((s) => s.trim()).filter(Boolean), fields, isActive: true };
if (editingFormId) {
await apiFetch(`/register/forms/${editingFormId}`, { method: 'PUT', body: JSON.stringify(body) });
setStatusMessage('Formular aktualisiert');
} else {
await apiFetch('/register/forms', { method: 'POST', body: JSON.stringify(body) });
setStatusMessage('Formular erstellt');
}
setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
setEditingFormId(null);
await loadGuildData(currentGuildId);
}
async function deleteForm(id: string) {
await apiFetch(`/register/forms/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Formular gelöscht');
await loadGuildData(currentGuildId);
}
async function sendFormPanel(formId: string) {
if (!supportLogin?.config?.panelChannelId) { setStatusMessage('Bitte zuerst Support Login konfigurieren'); return; }
await apiFetch(`/register/forms/${formId}/panel`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: supportLogin.config.panelChannelId }) });
setStatusMessage('Panel gesendet');
}
async function addStatusService() {
if (!statusServiceDraft.name) return;
await apiFetch('/statuspage/service', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, name: statusServiceDraft.name, url: statusServiceDraft.url, status: statusServiceDraft.status })
});
setStatusServiceDraft({ name: '', url: '', status: 'unknown' });
setStatusMessage('Service hinzugefügt');
await loadGuildData(currentGuildId);
}
async function deleteStatusService(id: string) {
await apiFetch(`/statuspage/service/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Service entfernt');
await loadGuildData(currentGuildId);
}
async function addStatsItem() {
if (!statsItemDraft.label) return;
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
const items = [...(draft.items || []), { key: statsItemDraft.label.toLowerCase().replace(/\s+/g, '_'), label: statsItemDraft.label, type: statsItemDraft.type }];
const updated = { ...draft, items };
setStatsDraft(updated);
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
setStatsItemDraft({ label: '', type: 'members' });
setStatusMessage('Stat-Item hinzugefügt');
await loadGuildData(currentGuildId);
}
async function deleteStatsItem(index: number) {
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
const items = (draft.items || []).filter((_: any, i: number) => i !== index);
const updated = { ...draft, items };
setStatsDraft(updated);
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
setStatusMessage('Stat-Item entfernt');
await loadGuildData(currentGuildId);
}
async function loadTicketMessages(ticketId: string) {
const res = await apiFetch<any>(`/tickets/${ticketId}/messages`);
setTicketMessages(res.messages || []);
}
async function updateTicketStatus(ticketId: string, status: string) {
await apiFetch(`/tickets/${ticketId}/status`, { method: 'POST', body: JSON.stringify({ status }) });
setStatusMessage('Status aktualisiert');
await loadGuildData(currentGuildId);
}
async function closeTicket(ticketId: string) {
await apiFetch(`/tickets/${ticketId}/close`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Ticket geschlossen');
await loadGuildData(currentGuildId);
}
async function loadAutomodStrikes() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}`);
setAutomodStrikes(res.strikes || []);
}
async function resetAutomodStrike(userId: string) {
await apiFetch(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
await loadAutomodStrikes();
}
async function loadRegisterApps(overrides?: { status?: string; formId?: string }) {
if (!currentGuildId) return;
const params = new URLSearchParams({ guildId: currentGuildId });
const status = overrides?.status ?? registerStatusFilter;
const formId = overrides?.formId ?? registerFormFilter;
if (status) params.set('status', status);
if (formId) params.set('formId', formId);
const res = await apiFetch<any>(`/register/apps?${params.toString()}`);
setRegisterApps(res.applications || []);
}
async function openAppDetail(id: string) {
if (selectedAppId === id) {
setSelectedAppId(null);
setAppNotes([]);
setAppHistory([]);
return;
}
setSelectedAppId(id);
const [notesRes, historyRes] = await Promise.all([
apiFetch<any>(`/register/apps/${id}/notes`),
apiFetch<any>(`/register/apps/${id}/history`)
]);
setAppNotes(notesRes.notes || []);
setAppHistory(historyRes.applications || []);
}
async function addAppNote() {
if (!selectedAppId || !noteDraft.trim()) return;
await apiFetch(`/register/apps/${selectedAppId}/notes`, { method: 'POST', body: JSON.stringify({ body: noteDraft.trim() }) });
setNoteDraft('');
const notesRes = await apiFetch<any>(`/register/apps/${selectedAppId}/notes`);
setAppNotes(notesRes.notes || []);
}
async function createTask() {
if (!taskDraft.title.trim() || !currentGuildId) return;
await apiFetch('/tasks', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, title: taskDraft.title.trim(), description: taskDraft.description.trim() || undefined })
});
setTaskDraft({ title: '', description: '' });
setStatusMessage('Aufgabe erstellt');
await loadGuildData(currentGuildId);
}
async function updateTaskStatus(id: string, status: string) {
await apiFetch(`/tasks/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) });
await loadGuildData(currentGuildId);
}
async function deleteTask(id: string) {
await apiFetch(`/tasks/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Aufgabe gelöscht');
await loadGuildData(currentGuildId);
}
async function loadWatchlist() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}`);
setWatchlistEntries(res.entries || []);
}
async function removeFromWatchlist(userId: string) {
await apiFetch(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
setStatusMessage('Von der Watchlist entfernt');
await loadWatchlist();
}
async function loadGrowthStats() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/growth?guildId=${encodeURIComponent(currentGuildId)}`);
setGrowthStats(res.stats || null);
}
async function loadInviteBreakdown() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/growth/invites?guildId=${encodeURIComponent(currentGuildId)}`);
setInviteBreakdown(res.breakdown || []);
setRecentJoins(res.recentJoins || []);
}
async function loadPermissionScan() {
if (!currentGuildId) return;
setPermissionScanLoading(true);
try {
const res = await apiFetch<any>(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`);
setPermissionScan(res.result || null);
} finally {
setPermissionScanLoading(false);
}
}
async function loadInfoPanels() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/panels?guildId=${encodeURIComponent(currentGuildId)}`);
setInfoPanels(res.panels || []);
}
async function createInfoPanel() {
if (!currentGuildId || !panelDraft.channelId || !panelDraft.title) return;
const items = panelDraft.type === 'faq'
? panelDraft.items.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 25).map((line) => {
const [q, ...rest] = line.split(':');
return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' };
}).filter((i) => i.question)
: undefined;
await apiFetch('/panels', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, channelId: panelDraft.channelId, type: panelDraft.type, title: panelDraft.title, description: panelDraft.description || undefined, items })
});
setPanelDraft({ type: 'rules', channelId: '', title: '', description: '', items: '' });
setStatusMessage('Panel wurde gepostet');
await loadInfoPanels();
}
async function deleteInfoPanel(id: string) {
await apiFetch(`/panels/${id}?guildId=${encodeURIComponent(currentGuildId)}`, { method: 'DELETE' });
setStatusMessage('Panel gelöscht');
await loadInfoPanels();
}
async function loadTicketTopics() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/ticketconfig?guildId=${encodeURIComponent(currentGuildId)}`);
setTicketTopics(res.topics || {});
}
async function saveTicketTopic() {
if (!currentGuildId || !ticketTopicDraft.topic.trim()) return;
const questions = ticketTopicDraft.questions.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5);
await apiFetch('/ticketconfig', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, topic: ticketTopicDraft.topic.trim().toLowerCase(), roleId: ticketTopicDraft.roleId || undefined, questions })
});
setTicketTopicDraft({ topic: '', roleId: '', questions: '' });
setStatusMessage('Ticket-Kategorie gespeichert');
await loadTicketTopics();
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
return (
<AppContext.Provider value={{
user, guilds, currentGuildId, section, guildInfo, overview, activity,
logs, tickets, pipeline, sla, automations, kbArticles, settings, modules,
birthday, reactionRoles, statuspage, serverStats, events, admin, statusMessage,
loading, supportLogin, registerForms, registerApps, musicStatus, ticketTab,
automationDraft, kbDraft, eventDraft, statusDraft, statsDraft, reactionDraft,
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels,
panelDraft, ticketTopics, ticketTopicDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
toggleModule, handleLogout, loadTicketData, loadTicketMessages,
updateTicketStatus, closeTicket, saveAutomation, saveKbArticle,
updateKbArticle, deleteKbArticle, updateAutomation, deleteAutomation,
saveSupportLogin, saveForm, deleteForm, sendFormPanel, addStatusService,
deleteStatusService, addStatsItem, deleteStatsItem, saveEvent, deleteEvent,
saveReactionRole, setTicketTab, setAutomationDraft, setKbDraft, setEventDraft,
setReactionDraft, setFormDraft, setEditingFormId, setRegisterTab,
setStatusServiceDraft, setStatsItemDraft, setTicketDetail, setKbEditDraft,
setAutomationEditDraft,
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel,
loadTicketTopics, setTicketTopicDraft, saveTicketTopic,
}}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}

View File

@@ -1,25 +0,0 @@
import { useState, useEffect } from 'react';
import { apiFetch } from '../utils/api';
type Channel = { id: string; name: string; type: string; parentId?: string };
type Role = { id: string; name: string; color: string };
type Category = { id: string; name: string };
export function useGuildResources(guildId?: string) {
const [channels, setChannels] = useState<Channel[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
if (!guildId) return;
apiFetch<{ channels: Channel[]; roles: Role[]; categories: Category[] }>(
`/guild/resources?guildId=${encodeURIComponent(guildId)}`
).then((res) => {
setChannels(res.channels || []);
setRoles(res.roles || []);
setCategories(res.categories || []);
}).catch(() => {});
}, [guildId]);
return { channels, roles, categories };
}

View File

@@ -1,14 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
export function useTheme() {
const [dark, setDark] = useState(() => localStorage.getItem('papo-theme') !== 'light');
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('papo-theme', dark ? 'dark' : 'light');
}, [dark]);
const toggle = useCallback(() => setDark((d) => !d), []);
return { dark, toggle };
}

View File

@@ -1,13 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AppProvider } from './context/AppContext';
import './app.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<AppProvider>
<App />
</AppProvider>
</React.StrictMode>
);

View File

@@ -1,125 +0,0 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Activity, Award, ClipboardList, Clock, Eye, Handshake, Server, Terminal, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate, formatDuration, guildIconUrl } from '../utils/formatters';
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
INFO: 'accent',
WARN: 'warning',
ERROR: 'danger',
};
export function Admin() {
const { user, admin } = useApp();
if (!user?.isAdmin) return null;
const overview = admin.overview || {};
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
const guildList: { id: string; name: string; icon?: string; memberCount: number; boostCount: number }[] = admin.guildList || [];
const usage = admin.usage || {};
const chartItems = activityPoints.slice(-12).map((p) => ({
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
value: p.count
}));
return (
<SectionCard title="Admin" subtitle="Bot-weite Übersichten">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Server size={18} />} label="Guilds" value={overview.guildCount ?? '-'} color="accent" />
<StatCard icon={<Activity size={18} />} label="Aktive Guilds (24h)" value={overview.activeGuilds24 ?? '-'} color="success" />
<StatCard icon={<Clock size={18} />} label="Uptime" value={formatDuration(overview.uptimeMs)} color="warning" />
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
</div>
<h3 className="mb-3 mt-6 text-sm font-semibold text-muted">Feature-Nutzung</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Award size={18} />} label="Abzeichen vergeben" value={usage.badgesAwarded ?? 0} color="accent" />
<StatCard icon={<ClipboardList size={18} />} label="Offene Aufgaben" value={usage.openTasks ?? 0} color="warning" />
<StatCard icon={<Eye size={18} />} label="Aktive Watchlist" value={usage.activeWatchlist ?? 0} color="danger" />
<StatCard icon={<Handshake size={18} />} label="Offene Partner-Anfragen" value={usage.pendingPartners ?? 0} />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Aktivität (letzte Stunden)</h3>
</CardHeader>
<CardContent className="p-5">
{chartItems.length ? (
<BarComparisonChart items={chartItems} />
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Activity size={24} />
Noch keine Aktivität erfasst
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Server</h3>
<Chip size="sm" variant="soft" color="accent">
{guildList.length} Guilds
</Chip>
</CardHeader>
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{guildList.length ? guildList.map((g) => (
<div key={g.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<AppAvatar size="sm" src={guildIconUrl(g as any)} name={g.name} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{g.name}</div>
<div className="flex items-center gap-3 text-xs text-muted">
<span className="flex items-center gap-1"><Users size={12} /> {g.memberCount}</span>
{g.boostCount > 0 && <span>💎 {g.boostCount}</span>}
</div>
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Server size={20} />
Keine Server
</div>
)}
</CardContent>
</Card>
</div>
<Card className="mt-5">
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
<Chip size="sm" variant="soft" color="warning">
{logs.length} Einträge
</Chip>
</CardHeader>
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{logs.length ? logs.slice(0, 30).map((log, i) => (
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip>
{log.category && <span className="text-xs text-muted">{log.category}</span>}
</div>
<p className="mt-1 text-muted">{log.message || '-'}</p>
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Terminal size={20} />
Keine Logs
</div>
)}
</CardContent>
</Card>
</SectionCard>
);
}

View File

@@ -1,516 +0,0 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Shield, Link2, Ban, AlertTriangle, Save, Info, MailWarning, AtSign, CaseUpper, X, UserPlus, Plus, Trash2, Siren } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect, type ChannelOption } from '../components/shared/ChannelSelect';
import { RoleSelect, type RoleOption } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
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.',
defaultAction: 'delete' as const,
},
{
key: 'linkFilter' as const,
icon: <Link2 size={16} />,
title: 'Link-Filter',
description: 'Blockiert Links, die nicht auf der Whitelist stehen.',
defaultAction: 'delete' as const,
},
{
key: 'inviteFilter' as const,
icon: <MailWarning size={16} />,
title: 'Einladungslink-Filter',
description: 'Blockiert Discord-Invites unabhängig vom Link-Filter.',
defaultAction: 'delete' as const,
},
{
key: 'mentionSpamFilter' as const,
icon: <AtSign size={16} />,
title: 'Mass-Mention-Schutz',
description: 'Verhindert Raid-artiges Massen-Pingen von Usern/Rollen.',
defaultAction: 'timeout' as const,
},
{
key: 'capsFilter' as const,
icon: <CaseUpper size={16} />,
title: 'Caps-Filter',
description: 'Entfernt Nachrichten mit überwiegend GROSSBUCHSTABEN.',
defaultAction: 'delete' as const,
},
{
key: 'spamFilter' as const,
icon: <AlertTriangle size={16} />,
title: 'Spam-Filter',
description: 'Erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.',
defaultAction: 'timeout' as const,
},
];
const ACTION_LABELS: Record<string, string> = {
delete: 'Nur löschen',
warn: 'Warnen',
timeout: 'Timeout',
kick: 'Kick',
ban: 'Ban',
};
const ESCALATION_ACTION_LABELS: Record<string, string> = {
timeout: 'Timeout',
kick: 'Kick',
ban: 'Ban',
};
function toList(value?: string) {
return (value || '').split(',').map((x) => x.trim()).filter(Boolean);
}
function RoleChipList({ roles, values, onChange }: { roles: RoleOption[]; values: string[]; onChange: (v: string[]) => void }) {
const [draft, setDraft] = useState('');
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{values.length ? values.map((roleId) => {
const role = roles.find((r) => r.id === roleId);
return (
<div key={roleId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
{role?.color && <span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />}
{role?.name || roleId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => onChange(values.filter((id) => id !== roleId))}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Rollen-Ausnahmen</p>}
</div>
<div className="flex gap-2">
<RoleSelect options={roles.filter((r) => !values.includes(r.id))} value={draft} onChange={setDraft} placeholder="Rolle ausnehmen" />
<Button
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
>
<Plus size={14} />
</Button>
</div>
</div>
);
}
function ChannelChipList({ channels, values, onChange }: { channels: ChannelOption[]; values: string[]; onChange: (v: string[]) => void }) {
const [draft, setDraft] = useState('');
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{values.length ? values.map((channelId) => {
const channel = channels.find((c) => c.id === channelId);
return (
<div key={channelId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
#{channel?.name || channelId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => onChange(values.filter((id) => id !== channelId))}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Kanal-Ausnahmen</p>}
</div>
<div className="flex gap-2">
<ChannelSelect options={channels.filter((c) => !values.includes(c.id))} value={draft} onChange={setDraft} placeholder="Kanal ausnehmen" />
<Button
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
>
<Plus size={14} />
</Button>
</div>
</div>
);
}
export function Automod() {
const {
settings, setSettings, saveSettingsPayload, currentGuildId,
automodStrikes, loadAutomodStrikes, resetAutomodStrike,
} = useApp();
const { channels, roles } = useGuildResources(currentGuildId);
const [roleDraft, setRoleDraft] = useState('');
const cfg = settings.automodConfig || {};
const whitelistRoles: string[] = cfg.whitelistRoles || [];
const strikeCfg = cfg.strikeConfig || { enabled: false, decayHours: 24, thresholds: [] };
const badwordRules: { pattern: string; isRegex?: boolean; severity?: string }[] =
cfg.badwordRules && cfg.badwordRules.length
? cfg.badwordRules
: (cfg.customBadwords || []).map((w: string) => ({ pattern: w, isRegex: false, severity: 'low' }));
useEffect(() => {
if (currentGuildId) loadAutomodStrikes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const patchConfig = (patch: Record<string, any>) =>
setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), ...patch } }));
const patchFilter = (key: string, patch: Record<string, any>) => {
const filters = { ...(cfg.filters || {}) };
filters[key] = { ...(filters[key] || {}), ...patch };
patchConfig({ filters });
};
const addWhitelistRole = () => {
if (!roleDraft || whitelistRoles.includes(roleDraft)) return;
patchConfig({ whitelistRoles: [...whitelistRoles, roleDraft] });
setRoleDraft('');
};
const updateBadword = (idx: number, patch: Record<string, any>) => {
patchConfig({ badwordRules: badwordRules.map((r, i) => (i === idx ? { ...r, ...patch } : r)) });
};
const removeBadword = (idx: number) => {
patchConfig({ badwordRules: badwordRules.filter((_, i) => i !== idx) });
};
const addBadword = () => {
patchConfig({ badwordRules: [...badwordRules, { pattern: '', isRegex: false, severity: 'low' }] });
};
const patchStrike = (patch: Record<string, any>) => patchConfig({ strikeConfig: { ...strikeCfg, ...patch } });
const thresholds: { count: number; action: string; timeoutMinutes?: number }[] = strikeCfg.thresholds || [];
const updateThreshold = (idx: number, patch: Record<string, any>) => {
patchStrike({ thresholds: thresholds.map((t, i) => (i === idx ? { ...t, ...patch } : t)) });
};
const removeThreshold = (idx: number) => {
patchStrike({ thresholds: thresholds.filter((_, i) => i !== idx) });
};
const addThreshold = () => {
patchStrike({ thresholds: [...thresholds, { count: 3, action: 'timeout', timeoutMinutes: 10 }] });
};
return (
<SectionCard title="Automod" subtitle="Filter, Aktionen, Eskalation und Sicherheit">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Automod</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<Shield size={16} />}
title="Automod aktiv"
description="Schaltet alle Filter unten gesammelt ein oder aus."
isSelected={settings.automodEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}
/>
<div className="flex flex-col gap-2">
{FILTERS.map((f) => {
const filterCfg = cfg.filters?.[f.key] || {};
const action = filterCfg.action || f.defaultAction;
const exemptRoleIds: string[] = filterCfg.exemptRoleIds || [];
const exemptChannelIds: string[] = filterCfg.exemptChannelIds || [];
return (
<div key={f.key} className="bg-surface-secondary rounded-xl p-3">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{f.icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={cfg[f.key] ?? true}
onChange={(v) => patchConfig({ [f.key]: v })}
/>
</div>
{(cfg[f.key] ?? true) && (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
<div className="flex items-end gap-2">
<TextField className="w-48">
<Label className="text-xs">Aktion bei Verstoß</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={action}
onChange={(e) => patchFilter(f.key, { action: e.target.value })}
>
{Object.entries(ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
{action === 'timeout' && (
<TextField className="w-36">
<Label className="text-xs">Timeout (Min.)</Label>
<Input
type="number" min="1"
value={String(filterCfg.timeoutMinutes ?? cfg.spamTimeoutMinutes ?? 10)}
onChange={(e) => patchFilter(f.key, { timeoutMinutes: Number(e.target.value || 10) })}
/>
</TextField>
)}
</div>
{f.key === 'linkFilter' && (
<TextField>
<Label className="text-xs">Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(cfg.linkWhitelist || []).join(', ')}
onChange={(e) => patchConfig({ linkWhitelist: toList(e.target.value) })}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
)}
{f.key === 'mentionSpamFilter' && (
<TextField className="w-56">
<Label className="text-xs">Max. Erwähnungen pro Nachricht</Label>
<Input
type="number" min="2"
value={String(cfg.maxMentions ?? 5)}
onChange={(e) => patchConfig({ maxMentions: Number(e.target.value || 5) })}
/>
</TextField>
)}
{f.key === 'spamFilter' && (
<div className="grid grid-cols-2 gap-2">
<TextField>
<Label className="text-xs">Nachrichten</Label>
<Input
type="number" min="2"
value={String(cfg.spamThreshold ?? 5)}
onChange={(e) => patchConfig({ spamThreshold: Number(e.target.value || 5) })}
/>
</TextField>
<TextField>
<Label className="text-xs">Zeitfenster (Sek.)</Label>
<Input
type="number" min="1"
value={String(Math.round((cfg.windowMs ?? 7000) / 1000))}
onChange={(e) => patchConfig({ windowMs: Number(e.target.value || 7) * 1000 })}
/>
</TextField>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Ausgenommene Rollen</Label>
<RoleChipList roles={roles} values={exemptRoleIds} onChange={(v) => patchFilter(f.key, { exemptRoleIds: v })} />
</div>
<div>
<Label className="text-xs">Ausgenommene Kanäle</Label>
<ChannelChipList channels={channels} values={exemptChannelIds} onChange={(v) => patchFilter(f.key, { exemptChannelIds: v })} />
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<TextField>
<Label>Log Channel</Label>
<ChannelSelect
options={channels}
value={cfg.logChannelId}
onChange={(id) => patchConfig({ logChannelId: id })}
placeholder="Channel für Automod-Logs wählen"
/>
</TextField>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: cfg }, 'Automod gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bad-Word-Regeln</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Wörter, Phrasen oder reguläre Ausdrücke mit eigenem Schweregrad. Ergänzt die eingebaute Standard-Liste.</p>
{badwordRules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
<Input
className="flex-1"
placeholder={rule.isRegex ? 'Regex, z.B. \\bwort\\d+\\b' : 'Wort oder Phrase'}
value={rule.pattern}
onChange={(e) => updateBadword(idx, { pattern: e.target.value })}
/>
<select
className="rounded-xl px-2 py-2 text-xs"
value={rule.severity || 'low'}
onChange={(e) => updateBadword(idx, { severity: e.target.value })}
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
<label className="flex items-center gap-1 text-xs text-muted shrink-0">
<AppSwitch aria-label="Regex" isSelected={!!rule.isRegex} onChange={(v) => updateBadword(idx, { isRegex: v })} />
Regex
</label>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => removeBadword(idx)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button size="sm" variant="tertiary" onPress={addBadword}>
<Plus size={14} /> Regel hinzufügen
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="flex items-center gap-2 text-base font-semibold"><Siren size={16} /> Eskalation / Strikes</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="flex items-center gap-3 rounded-xl bg-surface-secondary p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">Strike-System aktiv</div>
<div className="text-xs text-muted">Verstöße werden gezählt; ab einer Schwelle greift automatisch eine härtere Strafe.</div>
</div>
<AppSwitch aria-label="Strike-System aktiv" isSelected={!!strikeCfg.enabled} onChange={(v) => patchStrike({ enabled: v })} />
</div>
{strikeCfg.enabled && (
<>
<TextField className="w-48">
<Label className="text-xs">Verstöße verfallen nach (Std.)</Label>
<Input
type="number" min="0" placeholder="0 = nie"
value={String(strikeCfg.decayHours ?? 24)}
onChange={(e) => patchStrike({ decayHours: Number(e.target.value || 0) })}
/>
</TextField>
<div className="flex flex-col gap-2">
{thresholds.map((t, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
<TextField className="w-24">
<Label className="text-xs">Ab Anzahl</Label>
<Input type="number" min="1" value={String(t.count)} onChange={(e) => updateThreshold(idx, { count: Number(e.target.value || 1) })} />
</TextField>
<TextField className="w-32">
<Label className="text-xs">Aktion</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={t.action}
onChange={(e) => updateThreshold(idx, { action: e.target.value })}
>
{Object.entries(ESCALATION_ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
{t.action === 'timeout' && (
<TextField className="w-28">
<Label className="text-xs">Minuten</Label>
<Input type="number" min="1" value={String(t.timeoutMinutes ?? 10)} onChange={(e) => updateThreshold(idx, { timeoutMinutes: Number(e.target.value || 10) })} />
</TextField>
)}
<Button isIconOnly size="sm" variant="danger-soft" className="mt-4" onPress={() => removeThreshold(idx)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button size="sm" variant="tertiary" onPress={addThreshold}>
<Plus size={14} /> Schwelle hinzufügen
</Button>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Aktuelle Verstöße</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{automodStrikes.length ? automodStrikes.map((s) => (
<div key={s.userId} className="flex items-center justify-between gap-2 rounded-xl bg-surface-secondary p-3">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{s.userId}</div>
<div className="text-xs text-muted truncate">{s.reasons.join(' · ') || 'Keine Details'}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="rounded-full bg-danger-soft px-2 py-1 text-xs font-semibold text-danger">{s.count}</span>
<Button size="sm" variant="tertiary" onPress={() => resetAutomodStrike(s.userId)}>Zurücksetzen</Button>
</div>
</div>
)) : (
<p className="text-xs text-muted">Keine aktiven Verstöße.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Globale Rollen-Whitelist</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Mitglieder mit diesen Rollen werden von Automod komplett ignoriert (alle Filter).</p>
<div className="flex flex-wrap gap-2">
{whitelistRoles.length ? whitelistRoles.map((roleId) => {
const role = roles.find((r) => r.id === roleId);
return (
<div key={roleId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
{role?.color && <span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />}
{role?.name || roleId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => patchConfig({ whitelistRoles: whitelistRoles.filter((id) => id !== roleId) })}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Rollen ausgenommen</p>}
</div>
<div className="flex gap-2">
<RoleSelect
options={roles.filter((r) => !whitelistRoles.includes(r.id))}
value={roleDraft}
onChange={setRoleDraft}
placeholder="Rolle wählen"
/>
<Button size="sm" variant="tertiary" className="shrink-0" onPress={addWhitelistRole} isDisabled={!roleDraft}>
<UserPlus size={14} /> Hinzufügen
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-start gap-3 p-4">
<Info size={16} className="mt-0.5 shrink-0 text-accent" />
<p className="text-sm text-muted">Änderungen werden nach dem Speichern sofort aktiv, ohne dass der Bot neu gestartet werden muss.</p>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,94 +0,0 @@
import { Card, CardContent, CardHeader, InputGroup, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { CalendarDays, Save, Cake, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function Birthday() {
const { birthday, setBirthday, saveBirthday, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Einträge">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<Cake size={16} />}
title="Birthday aktiv"
description="Speichert Geburtstage und sendet automatisch Glückwünsche."
isSelected={birthday.config?.enabled !== false}
onChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}
/>
<TextField>
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={birthday.config?.channelId}
onChange={(id) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: id } }))}
placeholder="Channel für Geburtstagsnachrichten wählen"
/>
</TextField>
<TextField>
<Label>Sendezeit (Stunde)</Label>
<InputGroup>
<InputGroup.Prefix><Clock size={16} className="text-muted" /></InputGroup.Prefix>
<InputGroup.Input
type="number"
min="0"
max="23"
value={String(birthday.config?.sendHour ?? 9)}
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, sendHour: Number(e.target.value || 0) } }))}
/>
</InputGroup>
</TextField>
<TextField>
<Label>Template</Label>
<TextArea
placeholder="Alles Gute zum Geburtstag, {user}!"
value={birthday.config?.messageTemplate || ''}
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, messageTemplate: e.target.value } }))}
/>
</TextField>
<Separator />
<Button variant="primary" onPress={saveBirthday}><Save size={16} /> Speichern</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Gespeicherte Geburtstage ({(birthday.birthdays || []).length})</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(birthday.birthdays || []).length ? (birthday.birthdays || []).map((entry, i) => (
<div key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Cake size={14} className="text-accent" />
<span className="font-medium">{entry.userId}</span>
</div>
<Chip size="sm" variant="soft" color="accent">
{String(entry.birthDate || '').replace(/^--/, '')}
</Chip>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<CalendarDays size={20} />
Keine Einträge
</div>
)}
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,100 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Button, Separator, TextField, Label } from '@heroui/react';
import { Palette, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
const THEMES: { key: string; label: string; color: string }[] = [
{ key: 'orange', label: 'Orange', color: '#f97316' },
{ key: 'blue', label: 'Blau', color: '#3b82f6' },
{ key: 'green', label: 'Grün', color: '#22c55e' },
{ key: 'purple', label: 'Lila', color: '#a855f7' },
{ key: 'red', label: 'Rot', color: '#ef4444' }
];
export function Branding() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const branding = settings.brandingConfig || {};
const patch = (patchValue: Record<string, any>) =>
setSettings((s) => ({ ...s, brandingConfig: { ...(s.brandingConfig || {}), ...patchValue } }));
const previewColor = branding.embedColor || THEMES.find((t) => t.key === branding.theme)?.color || '#f97316';
return (
<SectionCard title="Branding" subtitle="Passe Papo an das Erscheinungsbild deines Servers an.">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader>
<div>
<CardTitle><Palette size={16} className="inline mr-1.5" /> Branding konfigurieren</CardTitle>
<CardDescription>Farbe, Logo, Footer und Bot-Name für dieses Dashboard und Bot-Embeds.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<TextField>
<Label>Embed-Farbe (Hex)</Label>
<Input placeholder="#f97316" value={branding.embedColor || ''} onChange={(e) => patch({ embedColor: e.target.value })} />
</TextField>
<TextField>
<Label>Logo-URL</Label>
<Input placeholder="https://..." value={branding.logoUrl || ''} onChange={(e) => patch({ logoUrl: e.target.value })} />
</TextField>
<TextField>
<Label>Footer-Text</Label>
<Input value={branding.footerText || ''} onChange={(e) => patch({ footerText: e.target.value })} />
</TextField>
<TextField>
<Label>Bot-Name im Dashboard</Label>
<Input placeholder="Papo" value={branding.botName || ''} onChange={(e) => patch({ botName: e.target.value })} />
</TextField>
<div>
<Label>Theme</Label>
<div className="mt-2 flex flex-wrap gap-2">
{THEMES.map((t) => (
<button
key={t.key}
type="button"
onClick={() => patch({ theme: t.key })}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm ${branding.theme === t.key ? 'border-accent bg-accent-soft' : 'border-border'}`}
>
<span className="size-3 rounded-full" style={{ backgroundColor: t.color }} />
{t.label}
</button>
))}
</div>
</div>
<Separator />
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ brandingConfig: branding }, 'Branding gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>So sehen deine Embeds ungefähr aus.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DiscordPreview
botName={branding.botName || 'Papo'}
title="Beispiel-Embed"
description="So wirkt dein gewähltes Branding auf Nachrichten von Papo."
footer={branding.footerText}
accentColor={previewColor}
/>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,194 +0,0 @@
import { Card, CardContent, CardHeader, Chip, Button, ScrollShadow } from '@heroui/react';
import {
Bot, CalendarDays, Users, Ticket, Shield, MessageSquare,
ChevronRight, Activity, Clock, ArrowUpRight, RefreshCw, Send,
Settings, Sparkles, Hash, Gauge, Zap, Bell, Tag, Command
} from 'lucide-react';
import { useApp } from '../context/AppContext';
import { formatDate, guildIconUrl } from '../utils/formatters';
import { StatCard } from '../components/shared/StatCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
export function Dashboard() {
const { guildInfo, guilds, currentGuildId, overview, activity, logs, setSection } = useApp();
const selectedGuild = guilds.find((g) => g.id === currentGuildId);
const moduleFlags = guildInfo?.modules || {};
const quickActions = [
{ key: 'tickets', label: 'Ticket Panel senden', icon: <Send size={16} /> },
{ key: 'serverstats', label: 'Sync starten', icon: <RefreshCw size={16} /> },
{ key: 'modules', label: 'Module aktualisieren', icon: <Zap size={16} /> },
{ key: 'settings', label: 'Einstellungen oeffnen', icon: <Settings size={16} /> },
];
return (
<div className="space-y-6">
<Card className="bg-surface-secondary">
<CardContent className="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 items-center gap-5">
<AppAvatar className="size-20 shrink-0" size="lg" src={guildIconUrl(selectedGuild)} name={guildInfo?.name || selectedGuild?.name} />
<div className="min-w-0">
<div className="flex items-center gap-3">
<h1 className="truncate text-3xl font-black tracking-tight">{guildInfo?.name || selectedGuild?.name}</h1>
<Chip color="success" size="sm" variant="soft">
<Bot size={12} /> Online
</Chip>
</div>
<div className="mt-1 text-sm text-muted">ID: {guildInfo?.id || selectedGuild?.id}</div>
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(moduleFlags).filter(([, v]) => v).map(([key]) => (
<Chip key={key} size="sm" variant="soft" color="accent">
{key.replace('Enabled', '').replace(/([A-Z])/g, ' $1').trim()}
</Chip>
))}
</div>
</div>
</div>
<div className="flex gap-2 shrink-0">
<Chip size="sm" variant="soft" color="accent">
<Gauge size={12} /> Ping: {guildInfo?.ping || '-'}ms
</Chip>
</div>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard icon={<Users size={18} />} label="Mitglieder" value={guildInfo?.memberCount ?? 0} />
<StatCard icon={<Activity size={18} />} label="Online" value={guildInfo?.onlineCount ?? '-'} />
<StatCard icon={<Hash size={18} />} label="Channels" value={`${guildInfo?.textCount || 0}`} />
<StatCard icon={<Shield size={18} />} label="Rollen" value={guildInfo?.roleCount ?? '-'} />
<StatCard icon={<ArrowUpRight size={18} />} label="Boost Level" value={guildInfo?.boostLevel ?? '-'} />
<StatCard icon={<Ticket size={18} />} label="Offene Tickets" value={overview?.tickets?.open ?? 0} color="warning" />
<StatCard icon={<Command size={18} />} label="Commands (24h)" value={activity?.commands24h ?? 0} />
<StatCard icon={<MessageSquare size={18} />} label="Nachrichten (24h)" value={activity?.messages24h ?? 0} />
<StatCard icon={<Bot size={18} />} label="Automod (24h)" value={activity?.automod24h ?? 0} color="danger" />
<StatCard icon={<Clock size={18} />} label="Uptime" value={guildInfo?.uptime || '-'} />
</div>
<div className="grid gap-5 xl:grid-cols-2 2xl:grid-cols-3">
<Card>
<CardHeader>
<div>
<h2 className="text-lg font-bold">Activity Bereich</h2>
<p className="mt-0.5 text-xs text-muted">Live Statistiken</p>
</div>
</CardHeader>
<CardContent>
<BarComparisonChart
items={[
{ label: 'Nachrichten', value: activity?.messages24h ?? 0 },
{ label: 'Commands', value: activity?.commands24h ?? 0 },
{ label: 'Automod', value: activity?.automod24h ?? 0 },
{ label: 'Neue User', value: activity?.newUsers24h ?? 0 },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">Guild Logs</h2>
<p className="mt-0.5 text-xs text-muted">Letzte Ereignisse</p>
</div>
<Button size="sm" variant="ghost" onPress={() => setSection('settings')}>
Alle <ChevronRight size={14} />
</Button>
</CardHeader>
<CardContent>
<ScrollShadow className="max-h-[320px] space-y-2 pr-1" hideScrollBar>
{logs.length ? logs.slice(0, 15).map((log, i) => (
<Card key={`${log.timestamp}-${i}`} className="bg-surface-tertiary">
<CardContent className="flex items-start gap-3 p-3">
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
log.level === 'error' ? 'bg-danger-soft text-danger' :
log.level === 'warn' ? 'bg-warning-soft text-warning' :
'bg-accent-soft text-accent-soft-foreground'
}`}>
{log.level === 'error' ? <Shield size={12} /> :
log.level === 'warn' ? <Bell size={12} /> :
<Activity size={12} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Chip
color={log.level === 'error' ? 'danger' : log.level === 'warn' ? 'warning' : 'default'}
size="sm"
variant="soft"
>
{(log.level || 'info').toUpperCase()}
</Chip>
<span className="text-xs text-muted">{formatDate(log.timestamp)}</span>
</div>
<p className="mt-1 text-sm text-foreground/80">
{log.category ? <span className="text-muted">[{log.category}] </span> : ''}{log.message || '-'}
</p>
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-6 text-center text-sm text-muted">
<Activity size={20} />
Keine Logs
</div>
)}
</ScrollShadow>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-bold">Quick Actions</h2>
<p className="mt-0.5 text-xs text-muted">Schnellzugriff</p>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{quickActions.map((action) => (
<Button
key={action.key}
variant="tertiary"
className="justify-start font-medium"
onPress={() => setSection(action.key as any)}
>
{action.icon} {action.label}
</Button>
))}
</CardContent>
</Card>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{(['tickets', 'supportlogin', 'automod', 'welcome', 'birthday', 'reactionroles'] as const).map((key) => {
const item = navItemMap[key];
return (
<Card
key={key}
role="button"
tabIndex={0}
onClick={() => setSection(key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSection(key); }}
className="cursor-pointer border border-transparent transition-colors hover:border-accent"
>
<CardContent className="flex flex-col items-start gap-2">
<Chip size="sm" variant="soft" color="accent">
{item.icon} {item.label}
</Chip>
<div className="font-semibold text-sm">{item.label}</div>
<div className="text-xs text-muted">Modul verwalten</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
const navItemMap: Record<string, { label: string; icon: React.ReactNode }> = {
tickets: { label: 'Ticketsystem', icon: <Ticket size={18} /> },
supportlogin: { label: 'Support Login', icon: <Send size={18} /> },
automod: { label: 'Automod', icon: <Shield size={18} /> },
welcome: { label: 'Willkommen', icon: <Sparkles size={18} /> },
birthday: { label: 'Birthday', icon: <CalendarDays size={18} /> },
reactionroles: { label: 'Reaction Roles', icon: <Tag size={18} /> },
};

View File

@@ -1,86 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { AudioLines, Save, Mic, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function DynamicVoice() {
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, categories } = useGuildResources(currentGuildId);
return (
<SectionCard title="Dynamic Voice" subtitle="Voice-Lobby, Template und Limits">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<AudioLines size={16} />}
title="Dynamic Voice aktiv"
description="Erstellt private Voice-Channels aus der konfigurierten Lobby."
isSelected={settings.dynamicVoiceEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}
/>
<TextField>
<Label>Lobby Channel</Label>
<ChannelSelect
options={channels.filter((c) => c.type === 'voice')}
value={settings.dynamicVoiceConfig?.lobbyChannelId}
onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: id } }))}
placeholder="Voice-Channel der Lobby wählen"
/>
</TextField>
<TextField>
<Label>Kategorie</Label>
<ChannelSelect
options={categories.map((c) => ({ ...c, type: 'category' }))}
value={settings.dynamicVoiceConfig?.categoryId}
onChange={(id) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: id } }))}
placeholder="Kategorie für neue Channels wählen"
/>
</TextField>
<TextField>
<Label>Template</Label>
<Input
placeholder="Channel-Name Template"
value={settings.dynamicVoiceConfig?.template || ''}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), template: e.target.value } }))}
/>
</TextField>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload({ dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Info</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Mic size={16} className="text-accent" />
<span className="text-muted">Benutzer erstellen eigene Voice-Channels durch Beitreten der Lobby</span>
</div>
<div className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Users size={16} className="text-success" />
<span className="text-muted">Channel-Owner können Limits und Berechtigungen verwalten</span>
</div>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,98 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { CalendarDays, Trash2, Plus, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Events() {
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Events" subtitle="Bestehende Events und schneller Neu-Anlage-Flow">
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Events ({(events || []).length})</h3>
<div className="space-y-3">
{(events || []).length ? (events || []).map((event) => (
<Card key={event.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays size={16} className="text-accent shrink-0" />
<span className="font-semibold text-sm truncate">{event.title}</span>
</div>
<Button size="sm" variant="danger-soft" onPress={() => deleteEvent(event.id)}>
<Trash2 size={14} /> Löschen
</Button>
</div>
<p className="text-sm text-muted">{event.description || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-2 text-xs text-muted">
<Clock size={12} />
{formatDate(event.startsAt)}
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<CalendarDays size={24} />
Keine Events
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neues Event</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input
placeholder="Event Name"
value={eventDraft.title}
onChange={(e) => setEventDraft((s) => ({ ...s, title: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea
placeholder="Event Beschreibung"
value={eventDraft.description}
onChange={(e) => setEventDraft((s) => ({ ...s, description: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={eventDraft.channelId}
onChange={(id) => setEventDraft((s) => ({ ...s, channelId: id }))}
placeholder="Channel für Erinnerungen wählen"
/>
</TextField>
<TextField>
<Label>Start (ISO)</Label>
<Input
type="datetime-local"
placeholder="2024-12-24T18:00"
value={eventDraft.startsAt}
onChange={(e) => setEventDraft((s) => ({ ...s, startsAt: e.target.value }))}
/>
</TextField>
<Button variant="primary" onPress={saveEvent}>
<Plus size={16} /> Event speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,138 +0,0 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { TrendingUp, TrendingDown, Gem, Link2, Handshake, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate } from '../utils/formatters';
export function Growth() {
const { currentGuildId, growthStats, loadGrowthStats, inviteBreakdown, recentJoins, loadInviteBreakdown } = useApp();
useEffect(() => {
if (currentGuildId) {
loadGrowthStats();
loadInviteBreakdown();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const stats = growthStats;
const chartItems = (stats?.dailyJoins || []).map((d) => ({
label: new Date(d.day).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }),
value: d.count
}));
return (
<SectionCard title="Wachstum" subtitle="Joins, Leaves, Invites und Booster im Überblick.">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<TrendingUp size={18} />} label="Joins (7 Tage)" value={stats?.joins7 ?? 0} color="success" />
<StatCard icon={<TrendingDown size={18} />} label="Leaves (7 Tage)" value={stats?.leaves7 ?? 0} color="danger" />
<StatCard icon={<Gem size={18} />} label="Booster" value={stats?.boosts ?? 0} color="accent" />
<StatCard icon={<Handshake size={18} />} label="Joins über Partner (30T)" value={stats?.partnerJoins30 ?? 0} color="warning" />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Joins (letzte 7 Tage)</h3>
</CardHeader>
<CardContent className="p-5">
{chartItems.length ? (
<BarComparisonChart items={chartItems} />
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<TrendingUp size={24} />
Noch keine Daten erfasst
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bester Invite (30 Tage)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
{stats?.bestInvite ? (
<div className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Link2 size={16} className="text-accent shrink-0" />
<div className="min-w-0">
<div className="font-medium">discord.gg/{stats.bestInvite.code}</div>
<div className="text-xs text-muted">{stats.bestInvite.uses} Beitritte</div>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Link2 size={20} />
Noch keine Invite-Nutzung erfasst
</div>
)}
<p className="text-xs text-muted">
Joins (30 Tage): {stats?.joins30 ?? 0} · Leaves (30 Tage): {stats?.leaves30 ?? 0}
</p>
</CardContent>
</Card>
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Invite-Übersicht (30 Tage)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{inviteBreakdown.length ? inviteBreakdown.map((inv) => (
<div key={inv.code} className="bg-surface-tertiary flex items-center justify-between gap-2 rounded-xl px-4 py-3 text-sm">
<div className="min-w-0">
<div className="font-medium truncate">discord.gg/{inv.code}</div>
<div className="text-xs text-muted truncate">
{inv.inviterId ? `von <@${inv.inviterId}>` : 'Unbekannter Ersteller'} · {inv.uses} Nutzungen gesamt
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft" color="accent">{inv.joins30} Joins</Chip>
{inv.suspiciousJoins30 > 0 && (
<Chip size="sm" variant="soft" color="danger">{inv.suspiciousJoins30} auffällig</Chip>
)}
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Link2 size={20} />
Keine Invite-Nutzung in den letzten 30 Tagen
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Letzte Beitritte</h3>
</CardHeader>
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{recentJoins.length ? recentJoins.map((j) => (
<div key={j.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Users size={14} className="text-muted shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate">
User-ID {j.userId}{j.inviterId ? ` · eingeladen von ${j.inviterId}` : ''}
</div>
<div className="text-xs text-muted">
{j.inviteCode ? `discord.gg/${j.inviteCode}` : 'Unbekannter Invite'} · {formatDate(j.createdAt)}
</div>
</div>
{j.suspicious && <Chip size="sm" variant="soft" color="danger">Verdächtig</Chip>}
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Users size={20} />
Noch keine Beitritte erfasst
</div>
)}
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,41 +0,0 @@
import { Card, CardContent } from '@heroui/react';
import { useApp } from '../context/AppContext';
import { guildIconUrl } from '../utils/formatters';
import { AppAvatar } from '../components/shared/AppAvatar';
export function GuildSelect() {
const { guilds, setCurrentGuildId } = useApp();
return (
<div className="mx-auto flex min-h-screen max-w-6xl flex-col items-center justify-center px-6 py-12">
<div className="mb-8 text-center">
<div className="bg-accent text-accent-foreground mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl text-2xl font-black">
P
</div>
<h1 className="text-3xl font-bold tracking-tight">Wähle einen Server</h1>
<p className="mt-2 text-muted">Wähle einen Discord-Server aus, um das Dashboard zu öffnen.</p>
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
{guilds.map((guild) => (
<Card
key={guild.id}
role="button"
tabIndex={0}
className="cursor-pointer border border-transparent transition-colors hover:border-accent"
onClick={() => setCurrentGuildId(guild.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setCurrentGuildId(guild.id); }}
>
<CardContent className="flex flex-row items-center gap-4 p-5">
<AppAvatar src={guildIconUrl(guild)} name={guild.name} size="lg" />
<div className="min-w-0">
<div className="truncate text-lg font-semibold">{guild.name}</div>
<div className="mt-0.5 text-sm text-muted">ID: {guild.id}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { Card, CardContent } from '@heroui/react';
import { Puzzle, CheckCircle, XCircle } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
export function ModulesPage() {
const { modules, toggleModule } = useApp();
const activeModules = modules.filter((m) => m.enabled);
const inactiveModules = modules.filter((m) => !m.enabled);
return (
<SectionCard title="Module" subtitle="Module direkt umschalten">
<div className="space-y-5">
{activeModules.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
<CheckCircle size={16} className="text-success" />
Aktive Module ({activeModules.length})
</h3>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{activeModules.map((module) => (
<Card key={module.key} className="bg-surface-secondary">
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
<div className="min-w-0">
<div className="font-semibold">{module.name}</div>
{module.description && (
<div className="text-sm text-muted truncate">{module.description}</div>
)}
</div>
<AppSwitch aria-label={module.name} isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
</CardContent>
</Card>
))}
</div>
</div>
)}
{inactiveModules.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold">
<XCircle size={16} className="text-muted" />
Deaktivierte Module ({inactiveModules.length})
</h3>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{inactiveModules.map((module) => (
<Card key={module.key}>
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
<div className="min-w-0">
<div className="font-semibold">{module.name}</div>
{module.description && (
<div className="text-sm text-muted truncate">{module.description}</div>
)}
</div>
<AppSwitch aria-label={module.name} isSelected={module.enabled} onChange={(v) => toggleModule(module.key, v)} />
</CardContent>
</Card>
))}
</div>
</div>
)}
{modules.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Puzzle size={24} />
Keine Module verfügbar
</div>
)}
</div>
</SectionCard>
);
}

View File

@@ -1,62 +0,0 @@
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
import { Music, Play, List, Repeat, ExternalLink } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
export function MusicPage() {
const { musicStatus } = useApp();
return (
<SectionCard title="Musik-Status" subtitle="Aktuelle Wiedergabe und Queues pro Guild.">
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-3">
<StatCard icon={<Music size={18} />} label="Aktive Guilds" value={musicStatus.activeGuilds} color="accent" />
<StatCard icon={<Play size={18} />} label="Aktive Sessions" value={musicStatus.sessions.length} color="success" />
</div>
<div className="space-y-3">
<h3 className="text-base font-semibold">Sessions ({musicStatus.sessions.length})</h3>
{musicStatus.sessions.length ? musicStatus.sessions.map((session) => (
<Card key={session.guildId}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Music size={16} className="text-accent" />
<span className="font-semibold text-sm">{session.guildId}</span>
</div>
<div className="flex gap-2">
<Chip size="sm" variant="soft">
<Repeat size={12} /> {session.loop}
</Chip>
<Chip size="sm" variant="soft">
<List size={12} /> {session.queueLength} in Queue
</Chip>
</div>
</div>
{session.nowPlaying ? (
<div className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm">
<span className="text-muted">Jetzt läuft: </span>
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent hover:underline">
{session.nowPlaying.title}
<ExternalLink size={12} />
</a>
</div>
) : (
<div className="bg-surface-tertiary rounded-xl px-4 py-3 text-sm text-muted">
Keine aktive Wiedergabe
</div>
)}
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Music size={24} />
Keine aktiven Musik-Sessions
</div>
)}
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,128 +0,0 @@
import { Card, CardContent, CardHeader, Chip, Button, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { LayoutPanelTop, Trash2, Send, ScrollText, LifeBuoy, FileSignature, Handshake, Tags, CalendarDays, CircleHelp } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { useGuildResources } from '../hooks/useGuildResources';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { EmptyState } from '../components/shared/EmptyState';
import type { InfoPanelType } from '../types';
import { useEffect } from 'react';
const TYPE_META: Record<InfoPanelType, { label: string; icon: React.ReactNode }> = {
rules: { label: 'Regeln', icon: <ScrollText size={14} /> },
support: { label: 'Support', icon: <LifeBuoy size={14} /> },
bewerbung: { label: 'Bewerbungen', icon: <FileSignature size={14} /> },
partner: { label: 'Partner', icon: <Handshake size={14} /> },
rollen: { label: 'Rollen', icon: <Tags size={14} /> },
events: { label: 'Events', icon: <CalendarDays size={14} /> },
faq: { label: 'FAQ', icon: <CircleHelp size={14} /> },
};
export function Panels() {
const { currentGuildId, infoPanels, loadInfoPanels, panelDraft, setPanelDraft, createInfoPanel, deleteInfoPanel } = useApp();
const { channels } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadInfoPanels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
return (
<SectionCard title="Info-Panels" subtitle="Feste Panels für Regeln, Support, Bewerbungen, Partner, Rollen, Events und FAQ mit passenden Buttons.">
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Panels ({infoPanels.length})</h3>
<div className="space-y-3">
{infoPanels.length ? infoPanels.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
{TYPE_META[p.type]?.icon || <LayoutPanelTop size={14} />}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{p.title}</div>
<div className="text-xs text-muted">#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{TYPE_META[p.type]?.label || p.type}</Chip>
<Button size="sm" variant="danger-soft" onPress={() => deleteInfoPanel(p.id)}>
<Trash2 size={14} />
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Panels erstellt" icon={<LayoutPanelTop size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neues Panel erstellen</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Typ</Label>
<select
aria-label="Panel-Typ"
className="w-full rounded-xl text-sm"
value={panelDraft.type}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, type: e.target.value as InfoPanelType }))}
>
{Object.entries(TYPE_META).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
</TextField>
<TextField>
<Label>Ziel-Kanal</Label>
<ChannelSelect options={channels} value={panelDraft.channelId} onChange={(id) => setPanelDraft((s: any) => ({ ...s, channelId: id }))} />
</TextField>
<TextField>
<Label>Titel</Label>
<Input value={panelDraft.title} onChange={(e) => setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} />
</TextField>
{panelDraft.type !== 'faq' && (
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={panelDraft.description} onChange={(e) => setPanelDraft((s: any) => ({ ...s, description: e.target.value }))} />
</TextField>
)}
{panelDraft.type === 'faq' && (
<TextField>
<Label>Fragen (eine Frage: Antwort pro Zeile)</Label>
<TextArea
rows={5}
placeholder="Wie erstelle ich ein Ticket?: Klicke auf den Support-Button."
value={panelDraft.items}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, items: e.target.value }))}
/>
</TextField>
)}
<Separator />
<p className="text-xs text-muted">
{panelDraft.type === 'support' && 'Erhält automatisch einen Ticket-Button.'}
{panelDraft.type === 'bewerbung' && 'Erhält automatisch einen Bewerben-Button, falls ein aktives Formular existiert.'}
{panelDraft.type === 'partner' && 'Erhält automatisch einen Partner-werden-Button.'}
{panelDraft.type === 'faq' && 'Erhält ein Dropdown mit den hinterlegten Fragen.'}
{['rules', 'rollen', 'events'].includes(panelDraft.type) && 'Reines Info-Embed ohne Button.'}
</p>
<Button variant="primary" onPress={createInfoPanel} isDisabled={!panelDraft.channelId || !panelDraft.title}>
<Send size={16} /> Panel posten
</Button>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,125 +0,0 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
import { ShieldAlert, ShieldX, Bot, Globe, MessageSquare, Users, Copy, Ban, RefreshCw, TriangleAlert } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
function EntryList({ entries, emptyMessage, icon }: { entries: { id: string; name: string }[]; emptyMessage: string; icon: React.ReactNode }) {
if (!entries.length) return <p className="py-3 text-center text-xs text-muted">{emptyMessage}</p>;
return (
<div className="flex flex-col gap-1.5">
{entries.slice(0, 10).map((e) => (
<div key={e.id} className="bg-surface-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
{icon}
<span className="truncate">{e.name}</span>
</div>
))}
{entries.length > 10 && <p className="pl-1 text-xs text-muted">+{entries.length - 10} weitere</p>}
</div>
);
}
export function Permissions() {
const { currentGuildId, permissionScan, permissionScanLoading, loadPermissionScan } = useApp();
useEffect(() => {
if (currentGuildId) loadPermissionScan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const r = permissionScan;
return (
<SectionCard
title="Rechte-Scanner"
subtitle="Sicherheitsanalyse der Server-Rollen, Rechte und Kanäle."
action={
<Button variant="primary" onPress={() => loadPermissionScan()} isDisabled={permissionScanLoading}>
<RefreshCw size={16} className={permissionScanLoading ? 'animate-spin' : ''} /> Scan starten
</Button>
}
>
{!r && !permissionScanLoading && (
<EmptyState message="Noch kein Scan durchgeführt. Klicke auf „Scan starten“." icon={<ShieldAlert size={24} />} />
)}
{r && (
<div className="flex flex-col gap-5">
{r.tooManyAdmins && (
<Card className="border-danger bg-danger-soft">
<CardContent className="flex items-center gap-3 p-4 text-sm">
<TriangleAlert size={18} className="shrink-0 text-danger" />
<span>Auffällig viele Administrator-Rechte vergeben ({r.adminRoles.length} Rollen, {r.adminMemberCount} Mitglieder mit Adminrechten). Prüfe, ob das notwendig ist.</span>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<ShieldAlert size={18} />} label="Admin-Rollen" value={r.adminRoles.length} color={r.tooManyAdmins ? 'danger' : 'default'} />
<StatCard icon={<Ban size={18} />} label="Ban-Rollen" value={r.banRoles.length} />
<StatCard icon={<Bot size={18} />} label="Bots mit gefährlichen Rechten" value={r.dangerousBots.length} color={r.dangerousBots.length ? 'warning' : 'default'} />
<StatCard icon={<Globe size={18} />} label="Öffentliche Kanäle" value={r.publicChannels.length} />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Admin-Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.adminRoles} emptyMessage="Keine Admin-Rollen" icon={<ShieldAlert size={14} className="text-danger shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Rollen, die bannen können</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.banRoles} emptyMessage="Keine Ban-Rollen" icon={<Ban size={14} className="text-warning shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Bots mit gefährlichen Rechten</h3></CardHeader>
<CardContent className="p-5">
{r.dangerousBots.length ? (
<div className="flex flex-col gap-1.5">
{r.dangerousBots.slice(0, 10).map((b) => (
<div key={b.id} className="bg-surface-tertiary flex flex-col gap-1 rounded-lg px-3 py-2 text-sm">
<div className="flex items-center gap-2"><Bot size={14} className="text-muted shrink-0" /><span className="truncate font-medium">{b.tag}</span></div>
<div className="flex flex-wrap gap-1 pl-5">
{b.perms.map((p) => <Chip key={p} size="sm" variant="soft" color="warning">{p}</Chip>)}
</div>
</div>
))}
</div>
) : <p className="py-3 text-center text-xs text-muted">Keine Bots mit gefährlichen Rechten</p>}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">@everyone kann schreiben</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.everyoneCanSendChannels} emptyMessage="Kein Kanal betroffen" icon={<MessageSquare size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Leere Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.emptyRoles} emptyMessage="Keine leeren Rollen" icon={<Users size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Nutzlose Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.uselessRoles} emptyMessage="Keine nutzlosen Rollen" icon={<ShieldX size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Doppelte Rollen (identische Rechte)</h3></CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{r.duplicateRoleGroups.length ? r.duplicateRoleGroups.map((group, i) => (
<div key={i} className="bg-surface-tertiary flex flex-wrap items-center gap-2 rounded-lg px-3 py-2 text-sm">
<Copy size={14} className="text-muted shrink-0" />
{group.map((role) => <Chip key={role.id} size="sm" variant="soft">{role.name}</Chip>)}
</div>
)) : <p className="py-3 text-center text-xs text-muted">Keine doppelten Rollen gefunden</p>}
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,137 +0,0 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, Input, Button, Chip, TextField, Label } from '@heroui/react';
import { Tag, Save, Plus, X } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function ReactionRoles() {
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp();
const { channels, roles } = useGuildResources(currentGuildId);
const [entryDraft, setEntryDraft] = useState({ emoji: '', roleId: '', label: '' });
const addEntry = () => {
if (!entryDraft.emoji.trim() || !entryDraft.roleId) return;
setReactionDraft((s) => ({
...s,
entries: [...s.entries, { emoji: entryDraft.emoji.trim(), roleId: entryDraft.roleId, label: entryDraft.label.trim(), description: '' }]
}));
setEntryDraft({ emoji: '', roleId: '', label: '' });
};
const removeEntry = (index: number) => {
setReactionDraft((s) => ({ ...s, entries: s.entries.filter((_: any, i: number) => i !== index) }));
};
return (
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Sets ({reactionRoles.length})</h3>
<div className="space-y-3">
{reactionRoles.length ? reactionRoles.map((set, i) => (
<Card key={set.id || i}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 items-center justify-center rounded-xl bg-accent-soft text-accent-soft-foreground">
<Tag size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm truncate">{set.title || 'Reaction Role'}</div>
<div className="text-xs text-muted truncate">Channel: {set.channelId || '-'}</div>
</div>
<Chip size="sm" variant="soft">{(set.entries?.length || 0)} Einträge</Chip>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Tag size={24} />
Keine Sets
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neues Set</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input
placeholder="Rollenauswahl"
value={reactionDraft.title}
onChange={(e) => setReactionDraft((s) => ({ ...s, title: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={reactionDraft.channelId}
onChange={(id) => setReactionDraft((s) => ({ ...s, channelId: id }))}
placeholder="Channel für die Nachricht wählen"
/>
</TextField>
<div>
<Label>Einträge</Label>
<div className="mt-2 flex flex-col gap-2">
{reactionDraft.entries.map((entry, i) => {
const role = roles.find((r) => r.id === entry.roleId);
return (
<div key={i} className="bg-default-soft flex items-center gap-2 rounded-xl py-1.5 pl-3 pr-1.5 text-sm">
<span>{entry.emoji}</span>
{role?.color && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: role.color }} />}
<span className="min-w-0 flex-1 truncate">{entry.label || role?.name || entry.roleId}</span>
<button
type="button"
className="flex size-5 shrink-0 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => removeEntry(i)}
>
<X size={12} />
</button>
</div>
);
})}
{!reactionDraft.entries.length && <p className="text-xs text-muted">Noch keine Einträge hinzugefügt.</p>}
</div>
<div className="mt-3 flex flex-col gap-2 rounded-xl border border-border p-3">
<div className="flex gap-2">
<Input
className="w-16 shrink-0"
placeholder="😀"
value={entryDraft.emoji}
onChange={(e) => setEntryDraft((s) => ({ ...s, emoji: e.target.value }))}
/>
<RoleSelect
options={roles}
value={entryDraft.roleId}
onChange={(id) => setEntryDraft((s) => ({ ...s, roleId: id }))}
placeholder="Rolle wählen"
/>
</div>
<Input
placeholder="Label (optional, z.B. Gamer)"
value={entryDraft.label}
onChange={(e) => setEntryDraft((s) => ({ ...s, label: e.target.value }))}
/>
<Button size="sm" variant="tertiary" onPress={addEntry} isDisabled={!entryDraft.emoji.trim() || !entryDraft.roleId}>
<Plus size={14} /> Eintrag hinzufügen
</Button>
</div>
</div>
<Button variant="primary" onPress={saveReactionRole} isDisabled={!reactionDraft.entries.length || !reactionDraft.channelId}>
<Save size={16} /> Reaction Role speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,241 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Tabs, Tab, Separator, TextField, Label } from '@heroui/react';
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText, History, MessageSquare } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
accepted: 'Akzeptiert',
invited: 'Zum Gespräch eingeladen',
rejected: 'Abgelehnt',
};
const STATUS_COLORS: Record<string, 'success' | 'danger' | 'warning' | 'accent' | 'default'> = {
pending: 'warning',
accepted: 'success',
invited: 'accent',
rejected: 'danger',
};
export function Register() {
const {
registerForms, registerApps, registerTab, setRegisterTab,
formDraft, setFormDraft, editingFormId, setEditingFormId,
saveForm, deleteForm, sendFormPanel,
registerStatusFilter, registerFormFilter, setRegisterStatusFilter, setRegisterFormFilter, loadRegisterApps,
selectedAppId, openAppDetail, appHistory, appNotes, noteDraft, setNoteDraft, addAppNote
} = useApp();
return (
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Anträge verwalten.">
<Tabs selectedKey={registerTab} variant="primary" onSelectionChange={(key) => setRegisterTab(String(key))}>
<Tabs.ListContainer>
<Tabs.List aria-label="Register Tabs">
<Tab id="forms">Formulare</Tab>
<Tab id="apps">Anträge</Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
{registerTab === 'forms' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Formulare ({registerForms.length})</h3>
<div className="space-y-3">
{registerForms.length ? registerForms.map((f) => (
<Card key={f.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-accent shrink-0" />
<span className="font-semibold text-sm truncate">{f.name}</span>
</div>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="ghost" onPress={() => {
setFormDraft({
name: f.name, description: f.description || '',
reviewChannelId: f.reviewChannelId || '',
notifyRoleIds: (f.notifyRoleIds || []).join(', '),
fields: (f.fields || []).map((fd) =>
fd.label + '|' + fd.type + (fd.required ? '|required' : '') + (fd.options ? '|' + fd.options.join(',') : '')
).join('\n')
});
setEditingFormId(f.id);
}}>
<Pencil size={14} />
</Button>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteForm(f.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
<p className="text-sm text-muted">{f.description || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-2 text-xs">
<Chip size="sm" variant="soft" color={f.isActive ? 'success' : 'default'}>
{f.isActive ? 'Aktiv' : 'Inaktiv'}
</Chip>
<span className="text-muted">{f.fields?.length || 0} Felder</span>
<Button size="sm" variant="tertiary" onPress={() => sendFormPanel(f.id)}>
<Send size={12} /> Panel senden
</Button>
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<ClipboardList size={24} />
Keine Formulare
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">{editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Name</Label>
<Input value={formDraft.name} onChange={(e) => setFormDraft((s) => ({ ...s, name: e.target.value }))} />
</TextField>
<TextField>
<Label>Beschreibung</Label>
<Input value={formDraft.description} onChange={(e) => setFormDraft((s) => ({ ...s, description: e.target.value }))} />
</TextField>
<TextField>
<Label>Review Channel ID</Label>
<Input value={formDraft.reviewChannelId} onChange={(e) => setFormDraft((s) => ({ ...s, reviewChannelId: e.target.value }))} />
</TextField>
<TextField>
<Label>Benachrichtigungs-Rollen (Komma-getrennt)</Label>
<Input value={formDraft.notifyRoleIds} onChange={(e) => setFormDraft((s) => ({ ...s, notifyRoleIds: e.target.value }))} />
</TextField>
<TextField>
<Label>Felder (label|type|required|options)</Label>
<TextArea rows={6} value={formDraft.fields} onChange={(e) => setFormDraft((s) => ({ ...s, fields: e.target.value }))} />
</TextField>
<p className="text-xs text-muted">
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
</p>
<div className="flex gap-2">
<Button variant="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
{editingFormId && (
<Button variant="tertiary" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
Abbrechen
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)}
{registerTab === 'apps' && (
<div className="mt-5">
<div className="mb-4 flex flex-wrap items-end gap-2">
<TextField className="w-56">
<Label className="text-xs">Formular/Position</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={registerFormFilter}
onChange={(e) => { setRegisterFormFilter(e.target.value); loadRegisterApps({ formId: e.target.value }); }}
>
<option value="">Alle</option>
{registerForms.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
</select>
</TextField>
<TextField className="w-48">
<Label className="text-xs">Status</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={registerStatusFilter}
onChange={(e) => { setRegisterStatusFilter(e.target.value); loadRegisterApps({ status: e.target.value }); }}
>
<option value="">Alle</option>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
</div>
<h3 className="mb-3 text-base font-semibold">Eingegangene Anträge ({registerApps.length})</h3>
<div className="space-y-3">
{registerApps.length ? registerApps.map((app) => (
<Card key={app.id}>
<CardContent className="flex flex-col gap-3 p-4">
<button type="button" className="flex items-center justify-between gap-2 text-left" onClick={() => openAppDetail(app.id)}>
<div className="min-w-0">
<div className="font-semibold truncate">{app.username || app.userId}</div>
<div className="text-xs text-muted truncate">{app.form?.name || 'Formular'}</div>
</div>
<Chip size="sm" variant="soft" color={STATUS_COLORS[app.status] || 'default'}>
{STATUS_LABELS[app.status] || app.status}
</Chip>
</button>
<div className="text-xs text-muted">{formatDate(app.createdAt)}</div>
{app.answers?.length ? (
<div className="space-y-1">
{app.answers.map((a, i) => (
<div key={i} className="text-sm">
<span className="text-muted">{a.label || 'Frage'}: </span>
{a.value}
</div>
))}
</div>
) : null}
{selectedAppId === app.id && (
<div className="mt-2 grid gap-4 border-t border-border pt-3 sm:grid-cols-2">
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><History size={14} /> Bisherige Bewerbungen</h4>
{appHistory.length ? (
<div className="space-y-1.5">
{appHistory.map((h) => (
<div key={h.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">{h.form?.name || 'Formular'} · {formatDate(h.createdAt)}</span>
<Chip size="sm" variant="soft" color={STATUS_COLORS[h.status] || 'default'}>{STATUS_LABELS[h.status] || h.status}</Chip>
</div>
))}
</div>
) : <p className="text-xs text-muted">Keine weiteren Bewerbungen dieses Nutzers</p>}
</div>
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><MessageSquare size={14} /> Interne Notizen</h4>
<div className="space-y-2">
{appNotes.length ? appNotes.map((n) => (
<div key={n.id} className="rounded-lg bg-surface-secondary p-2 text-xs">
<div className="mb-1 flex items-center justify-between text-muted">
<span className="font-medium text-default">{n.authorTag}</span>
<span>{formatDate(n.createdAt)}</span>
</div>
{n.body}
</div>
)) : <p className="text-xs text-muted">Keine Notizen</p>}
</div>
<TextArea
className="mt-2" rows={2} placeholder="Interne Notiz hinzufügen..."
value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)}
/>
<Button size="sm" variant="tertiary" className="mt-2" isDisabled={!noteDraft.trim()} onPress={addAppNote}>
Notiz speichern
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<ClipboardList size={24} />
Keine Anträge
</div>
)}
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,103 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { Activity, Save, Trash2, Plus, BarChart3 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
export function ServerStats() {
const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp();
const items = (statsDraft?.items || []);
return (
<SectionCard title="Server Stats" subtitle="Counter und Refresh-Intervall steuern">
<div className="grid gap-5 xl:grid-cols-[420px_1fr]">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<BarChart3 size={16} />}
title="Server Stats aktiv"
description="Zeigt Mitglieder-/Channel-Zahlen als Voice-Statistiken an."
isSelected={statsDraft?.enabled === true}
onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}
/>
<TextField>
<Label>Kategorie-Name</Label>
<Input
placeholder="Server Stats"
value={statsDraft?.categoryName || ''}
onChange={(e) => setStatsDraft((s) => ({ ...(s || {}), categoryName: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Refresh (Minuten)</Label>
<Input
type="number"
value={String(statsDraft?.refreshMinutes || 10)}
onChange={(e) => setStatsDraft((s) => ({ ...(s || {}), refreshMinutes: Number(e.target.value || 10) }))}
/>
</TextField>
<Button variant="primary" onPress={saveServerStats}>
<Save size={16} /> Server Stats speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Items ({items.length})</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
{items.length ? items.map((item, i) => (
<div key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Activity size={14} className="text-accent" />
<span className="font-medium">{item.label || item.key}</span>
<Chip size="sm" variant="soft">{item.type || '-'}</Chip>
</div>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteStatsItem(i)}>
<Trash2 size={14} />
</Button>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<BarChart3 size={20} />
Keine Items
</div>
)}
<Separator />
<div>
<h4 className="text-sm font-semibold mb-2">Item hinzufügen</h4>
<div className="flex flex-col gap-2">
<Input placeholder="Label" value={statsItemDraft.label} onChange={(e) => setStatsItemDraft((s) => ({ ...s, label: e.target.value }))} />
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={statsItemDraft.type}
onChange={(e) => setStatsItemDraft((s) => ({ ...s, type: e.target.value }))}
>
<option value="members">Mitglieder</option>
<option value="channels">Channels</option>
<option value="roles">Rollen</option>
<option value="boosts">Boosts</option>
<option value="online">Online</option>
<option value="custom">Custom</option>
</select>
<Button size="sm" variant="primary" onPress={addStatsItem}>
<Plus size={14} /> Hinzufügen
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,134 +0,0 @@
import { Card, CardContent, CardHeader, Button, Separator, TextField, Label } from '@heroui/react';
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2, ImageIcon, QrCode } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function SettingsPage() {
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels, roles } = useGuildResources(currentGuildId);
return (
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Allgemein</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Welcome Channel</Label>
<ChannelSelect
options={channels}
value={settings.welcomeChannelId}
onChange={(id) => setSettings((s) => ({ ...s, welcomeChannelId: id }))}
placeholder="Channel wählen"
/>
</TextField>
<TextField>
<Label>Log Channel</Label>
<ChannelSelect
options={channels}
value={settings.logChannelId}
onChange={(id) => setSettings((s) => ({ ...s, logChannelId: id }))}
placeholder="Channel wählen"
/>
</TextField>
<TextField>
<Label>Support Rolle</Label>
<RoleSelect
options={roles}
value={settings.supportRoleId}
onChange={(id) => setSettings((s) => ({ ...s, supportRoleId: id }))}
placeholder="Rolle wählen"
/>
</TextField>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Logging Kategorien</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<AppSwitch
isSelected={settings.loggingConfig?.categories?.joinLeave !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), joinLeave: v } } }))}
label={<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div>}
/>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.messageEdit !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageEdit: v } } }))}
label={<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div>}
/>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.messageDelete !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageDelete: v } } }))}
label={<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div>}
/>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.automodActions !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), automodActions: v } } }))}
label={<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div>}
/>
<AppSwitch
isSelected={settings.loggingConfig?.categories?.ticketActions !== false}
onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), ticketActions: v } } }))}
label={<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div>}
/>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bild-Moderation</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<ImageIcon size={16} />}
title="Bild-Moderation aktiv"
description="Scannt Bild-Anhänge auf QR-Codes und gleicht sie mit bekannten Scam-Mustern ab."
isSelected={settings.imageModerationConfig?.enabled === true}
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), enabled: v } }))}
/>
<AppSwitch
isSelected={settings.imageModerationConfig?.alertOnly === true}
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), alertOnly: v } }))}
label={<div className="flex items-center gap-2"><QrCode size={14} /> Nur bei erkanntem Scam-Verdacht alarmieren</div>}
description="Wenn aus, wird bei jedem gefundenen QR-Code alarmiert, nicht nur bei verdächtigen."
/>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,106 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { RadioTower, Save, Trash2, Plus, Activity as ActivityIcon } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import type { StatusService } from '../types';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function Statuspage() {
const { statusDraft, setStatusDraft, saveStatuspage, statusServiceDraft, setStatusServiceDraft, addStatusService, deleteStatusService, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const services = ((statusDraft?.services || []) as StatusService[]);
return (
<SectionCard title="Statuspage" subtitle="Statusseite und Service-Liste verwalten">
<div className="grid gap-5 xl:grid-cols-[420px_1fr]">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<RadioTower size={16} />}
title="Statuspage aktiv"
description="Postet und aktualisiert die Statusseite mit deinen Services."
isSelected={statusDraft?.enabled !== false}
onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}
/>
<TextField>
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={statusDraft?.channelId}
onChange={(id) => setStatusDraft((s) => ({ ...(s || {}), channelId: id }))}
placeholder="Channel für Status-Updates wählen"
/>
</TextField>
<TextField>
<Label>Intervall (ms)</Label>
<Input
type="number"
value={String(statusDraft?.intervalMs || 60000)}
onChange={(e) => setStatusDraft((s) => ({ ...(s || {}), intervalMs: Number(e.target.value || 60000) }))}
/>
</TextField>
<Button variant="primary" onPress={saveStatuspage}>
<Save size={16} /> Statuspage speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Services ({services.length})</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
{services.length ? services.map((service) => (
<div key={service.id} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-3 min-w-0">
<div className="size-2 rounded-full shrink-0" />
<div className="min-w-0">
<span className="font-medium">{service.name || 'Service'}</span>
<span className="ml-2 text-muted text-xs truncate">{service.url || ''}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={
service.status === 'operational' ? 'success' :
service.status === 'degraded' ? 'warning' :
service.status === 'down' ? 'danger' : 'default'
}>{service.status || 'unknown'}</Chip>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteStatusService(service.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<ActivityIcon size={20} />
Keine Services
</div>
)}
<Separator />
<div>
<h4 className="text-sm font-semibold mb-2">Service hinzufügen</h4>
<div className="flex flex-col gap-2">
<Input placeholder="Name" value={statusServiceDraft.name} onChange={(e) => setStatusServiceDraft((s) => ({ ...s, name: e.target.value }))} />
<Input placeholder="URL (optional)" value={statusServiceDraft.url} onChange={(e) => setStatusServiceDraft((s) => ({ ...s, url: e.target.value }))} />
<Button size="sm" variant="primary" onPress={addStatusService}>
<Plus size={14} /> Hinzufügen
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,137 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { UserRound, Save, Send } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { DiscordPreview, DiscordButton } from '../components/shared/DiscordPreview';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
export function SupportLogin() {
const { supportLogin, setSupportLogin, saveSupportLogin, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
return (
<SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren">
<div className="grid gap-5 xl:grid-cols-[1fr_400px]">
<Card>
<CardHeader>
<div>
<CardTitle>Panel-Konfiguration</CardTitle>
<CardDescription>Texte und Zielkanal mit normalen HeroUI-Feldern pflegen.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AppSwitch
isSelected={supportLogin?.config?.autoRefresh !== false}
onChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
label="Auto-Refresh aktiv"
/>
<TextField>
<Label>Panel Channel</Label>
<ChannelSelect
options={channels}
value={supportLogin?.config?.panelChannelId}
onChange={(id) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: id } } : s)}
placeholder="Channel für das Panel wählen"
/>
</TextField>
<TextField>
<Label>Titel</Label>
<Input
value={supportLogin?.config?.title || 'Support Login'}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: e.target.value } } : s)}
/>
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea
value={supportLogin?.config?.description || ''}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: e.target.value } } : s)}
/>
</TextField>
<TextField>
<Label>Login Button Label</Label>
<Input
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: e.target.value } } : s)}
/>
</TextField>
<TextField>
<Label>Logout Button Label</Label>
<Input
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: e.target.value } } : s)}
/>
</TextField>
<Separator />
<div className="flex gap-2">
<Button variant="primary" onPress={saveSupportLogin}>
<Save size={16} /> Speichern & Panel senden
</Button>
<Button variant="ghost">
<Send size={16} /> Panel manuell senden
</Button>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>So sieht das Panel auf Discord aus.</CardDescription>
</div>
</CardHeader>
<CardContent>
<DiscordPreview
title={supportLogin?.config?.title || 'Support Login'}
description={supportLogin?.config?.description || 'Melde dich als Support an/ab.'}
>
<DiscordButton variant="primary">{supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}</DiscordButton>
<DiscordButton variant="secondary">{supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}</DiscordButton>
</DiscordPreview>
</CardContent>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Aktive Supporter</CardTitle>
<CardDescription>Aktueller Status aus der Guild.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
<Card key={i} className="bg-surface-tertiary">
<CardContent className="flex items-center gap-3">
<AppAvatar name={s.username} size="sm" className="size-6" />
<span className="font-medium">{s.username || s.userId}</span>
<Chip size="sm" variant="soft" color="success" className="ml-auto">Online</Chip>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<UserRound size={20} />
Keine aktiven Supporter
</div>
)}
<p className="mt-2 text-xs text-muted">
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
</p>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,80 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, TextField, Label } from '@heroui/react';
import { ListChecks, Trash2, Plus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
const STATUS_LABELS: Record<string, string> = { open: 'Offen', in_progress: 'In Bearbeitung', done: 'Erledigt' };
const STATUS_COLORS: Record<string, 'warning' | 'accent' | 'success'> = { open: 'warning', in_progress: 'accent', done: 'success' };
export function Tasks() {
const { tasks, taskDraft, setTaskDraft, createTask, updateTaskStatus, deleteTask } = useApp();
return (
<SectionCard title="Team-Aufgaben" subtitle="Aufgaben für dein Team erstellen und Status pflegen.">
<div className="grid gap-5 xl:grid-cols-[1fr_360px]">
<div>
<h3 className="mb-3 text-base font-semibold">Aufgaben ({tasks.length})</h3>
<div className="space-y-3">
{tasks.length ? tasks.map((t) => (
<Card key={t.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold truncate">{t.title}</div>
<div className="text-xs text-muted">von {t.createdByTag} · {formatDate(t.createdAt)}</div>
</div>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteTask(t.id)}>
<Trash2 size={14} />
</Button>
</div>
{t.description && <p className="text-sm text-muted">{t.description}</p>}
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-xl px-3 py-2 text-sm"
value={t.status}
onChange={(e) => updateTaskStatus(t.id, e.target.value)}
>
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="done">Erledigt</option>
</select>
<Chip size="sm" variant="soft" color={STATUS_COLORS[t.status] || 'warning'}>
{STATUS_LABELS[t.status] || t.status}
</Chip>
{t.assigneeId && <span className="text-xs text-muted">Zugewiesen an User-ID {t.assigneeId}</span>}
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<ListChecks size={24} />
Keine Aufgaben
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neue Aufgabe</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input value={taskDraft.title} onChange={(e) => setTaskDraft((s) => ({ ...s, title: e.target.value }))} />
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={taskDraft.description} onChange={(e) => setTaskDraft((s) => ({ ...s, description: e.target.value }))} />
</TextField>
<p className="text-xs text-muted">Eine zuständige Person lässt sich aktuell nur über <code>/task create</code> in Discord zuweisen.</p>
<Button variant="primary" onPress={createTask} isDisabled={!taskDraft.title.trim()}>
<Plus size={16} /> Aufgabe erstellen
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,445 +0,0 @@
import { useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight, Tag, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { formatDate } from '../utils/formatters';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
import { TicketKanban } from '../components/shared/TicketKanban';
import { RoleSelect } from '../components/shared/RoleSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Tickets() {
const {
currentGuildId, tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
updateAutomation, deleteAutomation, overview,
ticketTopics, loadTicketTopics, ticketTopicDraft, setTicketTopicDraft, saveTicketTopic
} = useApp();
const { roles } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadTicketTopics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
return (
<SectionCard title="Ticketsystem" subtitle="Ticket-Übersicht, Pipeline, SLA, Automationen und Knowledge Base.">
<Tabs selectedKey={ticketTab} variant="primary" onSelectionChange={(key) => setTicketTab(String(key))}>
<Tabs.ListContainer>
<Tabs.List aria-label="Ticket Tabs">
<Tab id="overview">Übersicht</Tab>
<Tab id="pipeline">Pipeline</Tab>
<Tab id="sla">SLA</Tab>
<Tab id="automations">Automationen</Tab>
<Tab id="kb">Knowledge Base</Tab>
<Tab id="kategorien">Kategorien</Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
{ticketTab === 'overview' && (
<div className="mt-5 space-y-5">
<div className="grid gap-4 sm:grid-cols-4">
<StatCard icon={<Ticket size={18} />} label="Offen" value={overview?.tickets?.open ?? 0} color="warning" />
<StatCard icon={<Clock size={18} />} label="In Bearbeitung" value={overview?.tickets?.inProgress ?? 0} color="accent" />
<StatCard icon={<CheckCircle size={18} />} label="Geschlossen" value={overview?.tickets?.closed ?? 0} color="default" />
<StatCard icon={<UserRound size={18} />} label="Gesamt" value={tickets.length} />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<div>
<h3 className="mb-3 text-base font-semibold">Offene Tickets ({openTickets.length})</h3>
<div className="space-y-3">
{openTickets.length ? openTickets.map((t) => (
<Card key={t.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className={`size-2 rounded-full shrink-0 ${
t.status === 'neu' ? 'bg-warning' :
t.status === 'in_bearbeitung' ? 'bg-accent' :
t.status === 'warten_auf_user' ? 'bg-default' : 'bg-success'
}`} />
<span className="font-semibold text-sm truncate">{t.topic || 'Ticket'}</span>
</div>
<Chip size="sm" variant="soft" color={t.status === 'neu' ? 'warning' : t.status === 'erledigt' ? 'default' : 'accent'}>
{t.status || 'neu'}
</Chip>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<span>{t.category || 'Allgemein'}</span>
<span>·</span>
<span>{formatDate(t.createdAt)}</span>
{t.claimedById && (
<>
<span>·</span>
<Chip size="sm" variant="soft" color="success" className="h-5">Claimed</Chip>
</>
)}
</div>
<div className="flex gap-2">
<select
aria-label="Status"
className="w-40 rounded-xl text-sm"
value=""
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
>
<option value="">Status ändern</option>
<option value="neu">Neu</option>
<option value="in_bearbeitung">In Bearbeitung</option>
<option value="warten_auf_user">Warten auf User</option>
<option value="erledigt">Erledigt</option>
</select>
<Button size="sm" variant="danger" onPress={() => closeTicket(t.id)}>Schließen</Button>
<Button size="sm" variant="tertiary" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Keine offenen Tickets" icon={<Ticket size={24} />} />}
</div>
</div>
<div>
<h3 className="mb-3 text-base font-semibold">Alle Tickets ({tickets.length})</h3>
<div className="space-y-2">
{tickets.length ? tickets.map((t) => (
<div key={t.id} className="bg-surface flex items-center justify-between rounded-xl px-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{t.topic || t.id}</div>
<div className="text-xs text-muted">{t.category || '-'} · {formatDate(t.createdAt)}</div>
</div>
<Chip size="sm" variant="soft" color={t.status === 'neu' ? 'warning' : t.status === 'erledigt' ? 'default' : 'accent'}>
{t.status}
</Chip>
</div>
)) : <EmptyState message="Keine Tickets" />}
</div>
</div>
</div>
{ticketDetail && (
<Card>
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-lg font-bold">{ticketDetail.topic || 'Ticket-Details'}</h3>
<Button size="sm" variant="ghost" onPress={() => setTicketDetail(null)}>Schließen</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="rounded-lg bg-default-soft px-3 py-2">
<span className="text-muted">Status:</span> {ticketDetail.status}
</div>
<div className="rounded-lg bg-default-soft px-3 py-2">
<span className="text-muted">Priorität:</span> {ticketDetail.priority || 'normal'}
</div>
<div className="rounded-lg bg-default-soft px-3 py-2">
<span className="text-muted">Kategorie:</span> {ticketDetail.category || '-'}
</div>
</div>
<Separator />
<div>
<h4 className="mb-2 text-sm font-semibold">Nachrichten ({ticketMessages.length})</h4>
<div className="max-h-60 space-y-2 overflow-auto">
{ticketMessages.length ? ticketMessages.map((msg, i) => (
<div key={i} className="rounded-lg bg-default-soft px-3 py-2 text-sm">
<span className="text-xs text-muted">{msg.author?.tag || msg.authorId}: </span>
{msg.content || '(Embed)'}
</div>
)) : <p className="text-xs text-muted">Keine Nachrichten</p>}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
{ticketTab === 'pipeline' && (
<TicketKanban pipeline={pipeline} updateTicketStatus={updateTicketStatus} />
)}
{ticketTab === 'sla' && (
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">SLA pro Supporter</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(sla.supporters || []).length ? sla.supporters.map((row: any, i: number) => (
<div key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<UserRound size={14} className="text-muted" />
<span className="font-medium">{row.supporterId || '-'}</span>
</div>
<div className="flex gap-3 text-xs text-muted">
<span>{row.tickets || 0} Tickets</span>
<span>TTC: {row.avgTTC ? `${Math.round(row.avgTTC / 1000)}s` : '-'}</span>
<span>TTFR: {row.avgTTFR ? `${Math.round(row.avgTTFR / 1000)}s` : '-'}</span>
</div>
</div>
)) : <p className="text-xs text-muted text-center py-4">Keine Daten</p>}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">SLA pro Tag</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(sla.days || []).length ? sla.days.slice(-14).map((row: any, i: number) => (
<div key={i} className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-2 text-xs">
<span className="font-medium">{row.date || '-'}</span>
<div className="flex gap-2 text-muted">
<span>{row.tickets || 0}</span>
<span>TTC: {row.avgTTC ? `${Math.round(row.avgTTC / 1000)}s` : '-'}</span>
<span>TTFR: {row.avgTTFR ? `${Math.round(row.avgTTFR / 1000)}s` : '-'}</span>
</div>
</div>
)) : <p className="text-xs text-muted text-center py-4">Keine Daten</p>}
</CardContent>
</Card>
</div>
)}
{ticketTab === 'automations' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Regeln ({automations.length})</h3>
<div className="space-y-3">
{automations.length ? automations.map((rule) => (
<Card key={rule.id}>
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm">{rule.name || 'Automation'}</div>
<Chip size="sm" variant="soft" color={rule.active !== false ? 'success' : 'default'}>
{rule.active !== false ? 'Aktiv' : 'Inaktiv'}
</Chip>
</div>
<div className="flex gap-2">
<Button size="sm" variant="tertiary" onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
<Pencil size={14} /> Bearbeiten
</Button>
<Button size="sm" variant="danger-soft" onPress={() => deleteAutomation(rule.id)}>
<Trash2 size={14} /> Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Keine Regeln" icon={<FileText size={24} />} />}
</div>
</div>
<div>
{automationEditDraft ? (
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Automation bearbeiten</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Name</Label>
<Input value={automationEditDraft.name} onChange={(e) => setAutomationEditDraft((s) => ({ ...s, name: e.target.value }))} />
</TextField>
<TextField>
<Label>Kategorie / Zustand</Label>
<Input value={automationEditDraft.conditionValue} onChange={(e) => setAutomationEditDraft((s) => ({ ...s, conditionValue: e.target.value }))} />
</TextField>
<TextField>
<Label>Aktion / Nachricht</Label>
<TextArea value={automationEditDraft.actionValue} onChange={(e) => setAutomationEditDraft((s) => ({ ...s, actionValue: e.target.value }))} />
</TextField>
<div className="flex gap-2">
<Button variant="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
<Button variant="tertiary" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neue Automation</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Name</Label>
<Input value={automationDraft.name} onChange={(e) => setAutomationDraft((s) => ({ ...s, name: e.target.value }))} />
</TextField>
<TextField>
<Label>Kategorie / Zustand</Label>
<Input value={automationDraft.conditionValue} onChange={(e) => setAutomationDraft((s) => ({ ...s, conditionValue: e.target.value }))} />
</TextField>
<TextField>
<Label>Aktion / Nachricht</Label>
<TextArea value={automationDraft.actionValue} onChange={(e) => setAutomationDraft((s) => ({ ...s, actionValue: e.target.value }))} />
</TextField>
<Button variant="primary" onPress={saveAutomation}>Automation speichern</Button>
</CardContent>
</Card>
)}
</div>
</div>
)}
{ticketTab === 'kb' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Artikel ({kbArticles.length})</h3>
<div className="space-y-3">
{kbArticles.length ? kbArticles.map((article) => (
<Card key={article.id}>
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm truncate">{article.title || 'Artikel'}</div>
<Chip size="sm" variant="soft">{(article.keywords?.length || 0)} Keywords</Chip>
</div>
<p className="text-sm text-muted line-clamp-2">{article.content || '-'}</p>
<div className="flex gap-2">
<Button size="sm" variant="tertiary" onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
<Pencil size={14} /> Bearbeiten
</Button>
<Button size="sm" variant="danger-soft" onPress={() => deleteKbArticle(article.id)}>
<Trash2 size={14} /> Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Keine Artikel" icon={<FileText size={24} />} />}
</div>
</div>
<div>
{kbEditDraft ? (
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Artikel bearbeiten</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input value={kbEditDraft.title} onChange={(e) => setKbEditDraft((s) => ({ ...s, title: e.target.value }))} />
</TextField>
<TextField>
<Label>Keywords</Label>
<Input value={kbEditDraft.keywords} onChange={(e) => setKbEditDraft((s) => ({ ...s, keywords: e.target.value }))} />
</TextField>
<TextField>
<Label>Inhalt</Label>
<TextArea rows={5} value={kbEditDraft.content} onChange={(e) => setKbEditDraft((s) => ({ ...s, content: e.target.value }))} />
</TextField>
<div className="flex gap-2">
<Button variant="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
<Button variant="tertiary" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neuer KB-Artikel</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input value={kbDraft.title} onChange={(e) => setKbDraft((s) => ({ ...s, title: e.target.value }))} />
</TextField>
<TextField>
<Label>Keywords</Label>
<Input value={kbDraft.keywords} onChange={(e) => setKbDraft((s) => ({ ...s, keywords: e.target.value }))} />
</TextField>
<TextField>
<Label>Inhalt</Label>
<TextArea rows={5} value={kbDraft.content} onChange={(e) => setKbDraft((s) => ({ ...s, content: e.target.value }))} />
</TextField>
<Button variant="primary" onPress={saveKbArticle}>Artikel speichern</Button>
</CardContent>
</Card>
)}
</div>
</div>
)}
{ticketTab === 'kategorien' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Konfigurierte Kategorien ({Object.keys(ticketTopics).length})</h3>
<p className="mb-3 text-xs text-muted">
Der Kategorie-Slug entspricht dem Ticket-Grund (z.B. <code>ban</code>, <code>help</code>, <code>feedback</code>, <code>other</code>). Ohne Konfiguration bleibt das bisherige Verhalten unverändert.
</p>
<div className="space-y-3">
{Object.keys(ticketTopics).length ? Object.entries(ticketTopics).map(([topic, cfg]) => (
<Card key={topic}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
<Tag size={14} />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{topic}</div>
<div className="flex items-center gap-1 text-xs text-muted">
<Users size={12} />
{cfg.roleId ? roles.find((r) => r.id === cfg.roleId)?.name || cfg.roleId : 'Standard-Support-Rolle'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{cfg.questions?.length ?? 0} Fragen</Chip>
<Button
size="sm"
variant="tertiary"
onPress={() => setTicketTopicDraft({ topic, roleId: cfg.roleId || '', questions: (cfg.questions || []).join('\n') })}
>
<Pencil size={14} /> Bearbeiten
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Kategorien konfiguriert" icon={<Tag size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Kategorie {ticketTopicDraft.topic ? 'bearbeiten' : 'anlegen'}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Kategorie-Slug</Label>
<Input
placeholder="z.B. help, ban, feedback, other"
value={ticketTopicDraft.topic}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, topic: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Rolle, die gepingt wird</Label>
<RoleSelect options={roles} value={ticketTopicDraft.roleId} onChange={(id) => setTicketTopicDraft((s: any) => ({ ...s, roleId: id }))} placeholder="Standard-Support-Rolle" />
</TextField>
<TextField>
<Label>Fragen-Vorlage (eine pro Zeile, max. 5)</Label>
<TextArea
rows={5}
placeholder={'Was ist passiert?\nWann ist es aufgetreten?\nHast du einen Screenshot?'}
value={ticketTopicDraft.questions}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, questions: e.target.value }))}
/>
</TextField>
<p className="text-xs text-muted">Wenn Fragen hinterlegt sind, öffnet sich beim Ticket-Erstellen ein Formular statt sofort ein Kanal zu erstellen.</p>
<div className="flex gap-2">
<Button variant="primary" onPress={saveTicketTopic} isDisabled={!ticketTopicDraft.topic.trim()}>Speichern</Button>
{ticketTopicDraft.topic && (
<Button variant="tertiary" onPress={() => setTicketTopicDraft({ topic: '', roleId: '', questions: '' })}>Abbrechen</Button>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,36 +0,0 @@
import { Card, CardContent, Button } from '@heroui/react';
import { Eye, UserMinus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
export function Watchlist() {
const { watchlistEntries, removeFromWatchlist } = useApp();
return (
<SectionCard title="Watchlist" subtitle="Beobachtete Nutzer. Papo meldet auffälliges Verhalten im Log-Kanal.">
<h3 className="mb-3 text-base font-semibold">Beobachtete Nutzer ({watchlistEntries.length})</h3>
<div className="space-y-3">
{watchlistEntries.length ? watchlistEntries.map((w) => (
<Card key={w.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="font-semibold truncate">User-ID {w.userId}</div>
<div className="text-xs text-muted truncate">{w.reason || 'Kein Grund angegeben'}</div>
<div className="text-xs text-muted">hinzugefügt von {w.addedBy} · {formatDate(w.addedAt)}</div>
</div>
<Button size="sm" variant="danger-soft" onPress={() => removeFromWatchlist(w.userId)}>
<UserMinus size={14} /> Entfernen
</Button>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Eye size={24} />
Aktuell wird niemand beobachtet.
</div>
)}
</div>
</SectionCard>
);
}

View File

@@ -1,151 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Sparkles, Save, X } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
export function Welcome() {
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const imagePreview = settings.welcomeConfig?.embedImageData || settings.welcomeConfig?.embedImage;
const handleImageUpload = (file: File) => {
if (file.size > MAX_IMAGE_BYTES) {
alert('Bild ist zu groß (max. 3 MB).');
return;
}
const reader = new FileReader();
reader.onload = () => {
setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImageData: reader.result as string, embedImage: undefined } }));
};
reader.readAsDataURL(file);
};
const clearImage = () => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: undefined, embedImageData: undefined } }));
return (
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader>
<div>
<CardTitle>Welcome konfigurieren</CardTitle>
<CardDescription>Standardfelder fuer das Welcome-Embed.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ModuleActiveToggle
icon={<Sparkles size={16} />}
title="Welcome aktiv"
description="Sendet ein Begrüßungs-Embed, wenn neue Mitglieder beitreten."
isSelected={settings.welcomeConfig?.enabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}
/>
<TextField>
<Label>Channel</Label>
<ChannelSelect
options={channels}
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId}
onChange={(id) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: id } }))}
placeholder="Channel für Willkommensnachrichten wählen"
/>
</TextField>
<TextField>
<Label>Titel</Label>
<Input
placeholder="Willkommen {user}!"
value={settings.welcomeConfig?.embedTitle || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedTitle: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea
placeholder="Beschreibung des Embeds"
value={settings.welcomeConfig?.embedDescription || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedDescription: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Footer</Label>
<Input
placeholder={new Date().getFullYear().toString()}
value={settings.welcomeConfig?.embedFooter || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedFooter: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Bild-URL</Label>
<Input
placeholder="https://..."
value={settings.welcomeConfig?.embedImage || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: e.target.value, embedImageData: undefined } }))}
/>
</TextField>
<TextField>
<Label>oder Bild hochladen</Label>
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="text-sm text-muted"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload(file);
e.target.value = '';
}}
/>
</TextField>
{imagePreview && (
<div className="flex items-center gap-2">
<img src={imagePreview} alt="Vorschau" className="h-12 w-20 rounded object-cover" />
<Button size="sm" variant="danger-soft" onPress={clearImage}>
<X size={14} /> Bild entfernen
</Button>
</div>
)}
<Separator />
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>So sieht die Nachricht auf Discord aus.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DiscordPreview
title={settings.welcomeConfig?.embedTitle || 'Willkommen!'}
description={settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}
footer={settings.welcomeConfig?.embedFooter}
image={imagePreview}
/>
<p className="text-sm text-muted">
Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen.
</p>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,238 +0,0 @@
export type AppConfig = {
baseRoot?: string;
baseApi?: string;
baseAuth?: string;
baseDashboard?: string;
initialGuildId?: string;
};
export type User = {
username: string;
discriminator?: string;
isAdmin?: boolean;
};
export type Guild = {
id: string;
name: string;
icon?: string;
};
export type NavKey =
| 'overview'
| 'tickets'
| 'supportlogin'
| 'automod'
| 'welcome'
| 'dynamicvoice'
| 'birthday'
| 'reactionroles'
| 'statuspage'
| 'serverstats'
| 'register'
| 'music'
| 'settings'
| 'modules'
| 'events'
| 'tasks'
| 'watchlist'
| 'branding'
| 'growth'
| 'permissions'
| 'panels'
| 'admin';
export type TicketRecord = {
id: string;
topic?: string;
status?: string;
category?: string;
priority?: string;
createdAt?: string;
claimedById?: string | null;
};
export type StatusService = {
id: string;
name?: string;
url?: string;
status?: string;
uptimePct?: number;
lastCheckedAt?: string;
};
export type EventItem = {
id: string;
title: string;
description?: string;
startsAt?: string;
reminderMinutes?: number;
channelId?: string;
};
export type ReactionRoleSet = {
id: string;
title?: string;
description?: string;
channelId?: string;
messageId?: string;
entries?: Array<{ emoji: string; roleId: string; label?: string; description?: string }>;
};
export type ModuleItem = {
key: string;
name: string;
description?: string;
enabled: boolean;
};
export type LogEntry = {
level?: string;
category?: string;
message?: string;
timestamp?: string;
};
export type SettingsState = Record<string, any>;
export type NavItem = {
key: NavKey;
label: string;
icon: React.ReactNode;
};
export type SupportLoginConfig = {
panelChannelId?: string;
panelMessageId?: string;
title?: string;
description?: string;
loginLabel?: string;
logoutLabel?: string;
autoRefresh?: boolean;
};
export type SupportLoginStatus = {
active: { userId: string; username?: string; loggedInAt?: string }[];
};
export type RegisterFormField = {
id?: string;
label: string;
type: 'text' | 'paragraph' | 'select' | 'multi';
required?: boolean;
placeholder?: string;
options?: string[];
};
export type RegisterForm = {
id: string;
guildId: string;
name: string;
description?: string;
reviewChannelId?: string;
notifyRoleIds?: string[];
isActive: boolean;
fields: RegisterFormField[];
createdAt?: string;
};
export type RegisterApplication = {
id: string;
formId: string;
userId: string;
username?: string;
status: 'pending' | 'accepted' | 'invited' | 'rejected';
answers: { fieldId?: string; label?: string; value: string }[];
form?: RegisterForm;
createdAt?: string;
};
export type StaffTask = {
id: string;
guildId: string;
title: string;
description?: string | null;
status: 'open' | 'in_progress' | 'done';
assigneeId?: string | null;
createdBy: string;
createdByTag: string;
createdAt?: string;
updatedAt?: string;
};
export type WatchlistEntry = {
id: string;
guildId: string;
userId: string;
reason?: string | null;
addedBy: string;
addedAt?: string;
active: boolean;
};
export type GrowthStats = {
joins7: number;
leaves7: number;
joins30: number;
leaves30: number;
bestInvite: { code: string; uses: number } | null;
partnerJoins30: number;
boosts: number;
dailyJoins: { day: string; count: number }[];
};
export type InviteBreakdownEntry = {
code: string;
inviterId?: string;
uses: number;
joins30: number;
suspiciousJoins30: number;
};
export type RecentJoinEntry = {
id: string;
userId: string;
inviterId?: string | null;
inviteCode?: string | null;
suspicious: boolean;
createdAt: string;
};
export type PermissionScanResult = {
adminRoles: { id: string; name: string }[];
banRoles: { id: string; name: string }[];
dangerousBots: { id: string; tag: string; perms: string[] }[];
publicChannels: { id: string; name: string }[];
everyoneCanSendChannels: { id: string; name: string }[];
emptyRoles: { id: string; name: string }[];
duplicateRoleGroups: { id: string; name: string }[][];
uselessRoles: { id: string; name: string }[];
tooManyAdmins: boolean;
adminMemberCount: number;
};
export type InfoPanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
export type InfoPanel = {
id: string;
guildId: string;
channelId: string;
messageId?: string | null;
type: InfoPanelType;
title: string;
description?: string | null;
items?: { question: string; answer: string }[] | null;
createdAt?: string;
};
export type TicketTopicConfig = {
roleId?: string;
questions?: string[];
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;
queueLength: number;
loop: 'off' | 'song' | 'queue';
};

View File

@@ -1,29 +0,0 @@
import type { AppConfig } from '../types';
const config: AppConfig = (window as any).__PAPO__ || {};
export function apiUrl(path: string) {
const base = config.baseApi || '/api';
return `${base}${path}`;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(apiUrl(path), {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers || {})
}
});
if (response.status === 401) {
window.location.href = `${config.baseAuth || '/auth'}/discord`;
throw new Error('unauthorized');
}
if (!response.ok) {
throw new Error(`request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import {
Activity, AudioLines, CalendarDays, ClipboardList, Home,
LogIn, Music, Puzzle, RadioTower, Settings, Shield, Sparkles,
Tag, Ticket, Wrench
} from 'lucide-react';
import type { NavItem } from '../types';
export const navItems: NavItem[] = [
{ key: 'overview', label: 'Übersicht', icon: <Home size={20} /> },
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={20} /> },
{ key: 'supportlogin', label: 'Support Login', icon: <LogIn size={20} /> },
{ key: 'automod', label: 'Automod', icon: <Shield size={20} /> },
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={20} /> },
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={20} /> },
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={20} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={20} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={20} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={20} /> },
{ key: 'register', label: 'Registrierung', icon: <ClipboardList size={20} /> },
{ key: 'music', label: 'Musik', icon: <Music size={20} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={20} /> },
{ key: 'modules', label: 'Module', icon: <Puzzle size={20} /> },
{ key: 'events', label: 'Events', icon: <CalendarDays size={20} /> },
{ key: 'admin', label: 'Admin', icon: <Wrench size={20} /> }
];

View File

@@ -1,27 +0,0 @@
import type { Guild } from '../types';
export function formatDate(value?: string | number | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.toLocaleDateString('de-DE')} ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
export function formatDuration(ms?: number | null) {
if (!ms || ms < 0) return '-';
const totalMinutes = Math.floor(ms / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes || !parts.length) parts.push(`${minutes}m`);
return parts.join(' ');
}
export function guildIconUrl(guild?: Guild | null) {
if (!guild) return undefined;
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
return undefined;
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()]
});

View File

@@ -1,199 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
RUNNER_VERSION="${RUNNER_VERSION:-2.0.0}"
RUNNER_USER="${RUNNER_USER:-act_runner}"
RUNNER_HOME="${RUNNER_HOME:-/var/lib/act_runner}"
RUNNER_CONFIG_DIR="${RUNNER_CONFIG_DIR:-/etc/act_runner}"
RUNNER_CONFIG_FILE="${RUNNER_CONFIG_FILE:-$RUNNER_CONFIG_DIR/config.yaml}"
RUNNER_BINARY_PATH="${RUNNER_BINARY_PATH:-/usr/local/bin/gitea-runner}"
RUNNER_COMPAT_SYMLINK_PATH="${RUNNER_COMPAT_SYMLINK_PATH:-/usr/local/bin/act_runner}"
RUNNER_NAME="${RUNNER_NAME:-$(hostname)}"
RUNNER_LABELS="${RUNNER_LABELS:-linux_amd64:host,ubuntu-latest:docker://node:20-bookworm}"
GITEA_INSTANCE_URL="${GITEA_INSTANCE_URL:-}"
GITEA_RUNNER_TOKEN="${GITEA_RUNNER_TOKEN:-}"
REGISTER_RUNNER="${REGISTER_RUNNER:-true}"
detect_runner_arch() {
case "$(uname -m)" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
echo "Nicht unterstützte Architektur: $(uname -m)" >&2
exit 1
;;
esac
}
log() {
printf '[RUNNER] %s\n' "$1"
}
require_root() {
if [ "${EUID}" -ne 0 ]; then
echo "Dieses Script muss als root laufen." >&2
exit 1
fi
}
install_packages() {
log "Installiere Systempakete"
apt update
apt install -y docker.io curl wget unzip git ca-certificates python3
systemctl enable --now docker
}
create_runner_user() {
log "Erstelle Runner-User und Verzeichnisse"
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
useradd --system --create-home --shell /bin/bash "$RUNNER_USER"
fi
usermod -aG docker "$RUNNER_USER"
mkdir -p "$RUNNER_HOME" "$RUNNER_CONFIG_DIR"
chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" "$RUNNER_CONFIG_DIR"
}
install_runner_binary() {
local tmp_dir runner_arch asset_name download_url
log "Installiere act_runner ${RUNNER_VERSION}"
tmp_dir="$(mktemp -d)"
runner_arch="$(detect_runner_arch)"
asset_name="gitea-runner-${RUNNER_VERSION}-linux-${runner_arch}"
download_url="https://dl.gitea.com/gitea-runner/${RUNNER_VERSION}/${asset_name}"
curl -fsSL "$download_url" -o "${tmp_dir}/gitea-runner"
install -m 0755 "${tmp_dir}/gitea-runner" "$RUNNER_BINARY_PATH"
ln -sf "$RUNNER_BINARY_PATH" "$RUNNER_COMPAT_SYMLINK_PATH"
rm -rf "$tmp_dir"
"$RUNNER_BINARY_PATH" --version
}
generate_config() {
log "Erzeuge Runner-Konfiguration"
sudo -u "$RUNNER_USER" -H bash -lc "\"$RUNNER_BINARY_PATH\" generate-config > \"$RUNNER_CONFIG_FILE\""
python3 - "$RUNNER_CONFIG_FILE" "$RUNNER_LABELS" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
labels = [label.strip() for label in sys.argv[2].split(",") if label.strip()]
content = config_path.read_text(encoding="utf-8")
lines = content.splitlines()
out = []
in_runner = False
labels_written = False
skip_existing_labels = False
for idx, line in enumerate(lines):
stripped = line.strip()
if stripped == "runner:":
in_runner = True
out.append(line)
continue
if in_runner and line and not line.startswith(" "):
if not labels_written:
out.append(" labels:")
for label in labels:
out.append(f" - \"{label}\"")
labels_written = True
in_runner = False
if in_runner and stripped.startswith("labels:"):
skip_existing_labels = True
continue
if skip_existing_labels:
if line.startswith(" - ") or stripped == "":
continue
skip_existing_labels = False
out.append(line)
if in_runner and not labels_written:
out.append(" labels:")
for label in labels:
out.append(f" - \"{label}\"")
config_path.write_text("\n".join(out) + "\n", encoding="utf-8")
PY
chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_CONFIG_FILE"
}
register_runner() {
if [ "$REGISTER_RUNNER" != "true" ]; then
log "Runner-Registrierung übersprungen"
return
fi
if [ -z "$GITEA_INSTANCE_URL" ] || [ -z "$GITEA_RUNNER_TOKEN" ]; then
echo "Für die Registrierung werden GITEA_INSTANCE_URL und GITEA_RUNNER_TOKEN benötigt." >&2
exit 1
fi
log "Registriere Runner bei ${GITEA_INSTANCE_URL}"
sudo -u "$RUNNER_USER" -H bash -lc "cd \"$RUNNER_HOME\" && rm -f .runner && \"$RUNNER_BINARY_PATH\" register --no-interactive --instance \"$GITEA_INSTANCE_URL\" --token \"$GITEA_RUNNER_TOKEN\" --name \"$RUNNER_NAME\" --labels \"$RUNNER_LABELS\""
}
install_service() {
log "Installiere systemd-Service"
cat >/etc/systemd/system/act_runner.service <<EOF
[Unit]
Description=Gitea Actions runner
Documentation=https://docs.gitea.com/usage/actions/act-runner
After=docker.service
Requires=docker.service
[Service]
User=${RUNNER_USER}
WorkingDirectory=${RUNNER_HOME}
ExecStart=${RUNNER_BINARY_PATH} daemon --config ${RUNNER_CONFIG_FILE}
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now act_runner
}
print_summary() {
log "Fertig"
echo
echo "Service-Status:"
systemctl --no-pager --full status act_runner || true
echo
echo "Wichtige Pfade:"
echo " Binary: ${RUNNER_BINARY_PATH}"
echo " Symlink: ${RUNNER_COMPAT_SYMLINK_PATH}"
echo " Config: ${RUNNER_CONFIG_FILE}"
echo " Home: ${RUNNER_HOME}"
echo
echo "Beispiel mit direkter Registrierung:"
echo " sudo GITEA_INSTANCE_URL=https://gitea.example.tld GITEA_RUNNER_TOKEN=TOKEN ./install-gitea-runner.sh"
}
main() {
require_root
install_packages
create_runner_user
install_runner_binary
generate_config
register_runner
install_service
print_summary
}
main "$@"

17
node_modules/.bin/acorn generated vendored
View File

@@ -1 +1,16 @@
../acorn/bin/acorn
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../acorn/bin/acorn" "$@"
else
exec node "$basedir/../acorn/bin/acorn" "$@"
fi

17
node_modules/.bin/mime generated vendored
View File

@@ -1 +1,16 @@
../mime/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../mime/cli.js" "$@"
else
exec node "$basedir/../mime/cli.js" "$@"
fi

17
node_modules/.bin/mkdirp generated vendored
View File

@@ -1 +1,16 @@
../mkdirp/bin/cmd.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../mkdirp/bin/cmd.js" "$@"
else
exec node "$basedir/../mkdirp/bin/cmd.js" "$@"
fi

17
node_modules/.bin/prisma generated vendored
View File

@@ -1 +1,16 @@
../prisma/build/index.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../prisma/build/index.js" "$@"
else
exec node "$basedir/../prisma/build/index.js" "$@"
fi

17
node_modules/.bin/resolve generated vendored
View File

@@ -1 +1,16 @@
../resolve/bin/resolve
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../resolve/bin/resolve" "$@"
else
exec node "$basedir/../resolve/bin/resolve" "$@"
fi

17
node_modules/.bin/rimraf generated vendored
View File

@@ -1 +1,16 @@
../rimraf/bin.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rimraf/bin.js" "$@"
else
exec node "$basedir/../rimraf/bin.js" "$@"
fi

17
node_modules/.bin/tree-kill generated vendored
View File

@@ -1 +1,16 @@
../tree-kill/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../tree-kill/cli.js" "$@"
else
exec node "$basedir/../tree-kill/cli.js" "$@"
fi

17
node_modules/.bin/ts-node generated vendored
View File

@@ -1 +1,16 @@
../ts-node/dist/bin.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin.js" "$@"
fi

17
node_modules/.bin/ts-node-cwd generated vendored
View File

@@ -1 +1,16 @@
../ts-node/dist/bin-cwd.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-cwd.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-cwd.js" "$@"
fi

17
node_modules/.bin/ts-node-dev generated vendored
View File

@@ -1 +1,16 @@
../ts-node-dev/lib/bin.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node-dev/lib/bin.js" "$@"
else
exec node "$basedir/../ts-node-dev/lib/bin.js" "$@"
fi

17
node_modules/.bin/ts-node-esm generated vendored
View File

@@ -1 +1,16 @@
../ts-node/dist/bin-esm.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-esm.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-esm.js" "$@"
fi

17
node_modules/.bin/ts-node-script generated vendored
View File

@@ -1 +1,16 @@
../ts-node/dist/bin-script.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-script.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-script.js" "$@"
fi

View File

@@ -1 +1,16 @@
../ts-node/dist/bin-transpile.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-transpile.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-transpile.js" "$@"
fi

17
node_modules/.bin/ts-script generated vendored
View File

@@ -1 +1,16 @@
../ts-node/dist/bin-script-deprecated.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-script-deprecated.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-script-deprecated.js" "$@"
fi

17
node_modules/.bin/tsc generated vendored
View File

@@ -1 +1,16 @@
../typescript/bin/tsc
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

17
node_modules/.bin/tsnd generated vendored
View File

@@ -1 +1,16 @@
../ts-node-dev/lib/bin.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node-dev/lib/bin.js" "$@"
else
exec node "$basedir/../ts-node-dev/lib/bin.js" "$@"
fi

17
node_modules/.bin/tsserver generated vendored
View File

@@ -1 +1,16 @@
../typescript/bin/tsserver
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

823
node_modules/.package-lock.json generated vendored
View File

@@ -4,16 +4,6 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -274,430 +264,6 @@
"scripts/actions/documentation"
]
},
"node_modules/@jimp/core": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
"integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==",
"license": "MIT",
"dependencies": {
"@jimp/file-ops": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"await-to-js": "^3.0.0",
"exif-parser": "^0.1.12",
"file-type": "^21.3.3",
"mime": "3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/core/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@jimp/diff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz",
"integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==",
"license": "MIT",
"dependencies": {
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"pixelmatch": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/file-ops": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz",
"integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-bmp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz",
"integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"bmp-ts": "^1.0.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-gif": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz",
"integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"gifwrap": "^0.10.1",
"omggif": "^1.0.10"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-jpeg": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz",
"integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"jpeg-js": "^0.4.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-png": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz",
"integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"pngjs": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-tiff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz",
"integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"utif2": "^4.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz",
"integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blur": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz",
"integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-circle": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz",
"integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-color": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz",
"integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"tinycolor2": "^1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-contain": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz",
"integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-cover": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz",
"integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-crop": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz",
"integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-displace": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz",
"integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-dither": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz",
"integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-fisheye": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz",
"integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-flip": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz",
"integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-hash": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz",
"integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"any-base": "^1.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-mask": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz",
"integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-print": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz",
"integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/types": "1.6.1",
"parse-bmfont-ascii": "^1.0.6",
"parse-bmfont-binary": "^1.0.6",
"parse-bmfont-xml": "^1.1.6",
"simple-xml-to-json": "^1.2.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-quantize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz",
"integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz",
"integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-rotate": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz",
"integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-threshold": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz",
"integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/types": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz",
"integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -855,84 +421,22 @@
"@snazzah/davey-win32-x64-msvc": "0.1.8"
}
},
"node_modules/@snazzah/davey-linux-x64-gnu": {
"node_modules/@snazzah/davey-win32-x64-msvc": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.8.tgz",
"integrity": "sha512-yghgG7iXZUHy734Cq3PcgrbRnLhhB233JNTX5VPRxRqdwFAg2MzAJ2iSWpP12K6hSqKq9hw0sdt8CNpr0mEXjQ==",
"resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.8.tgz",
"integrity": "sha512-JKIco1miwtM4NgVwU/H9TJdUaSlJ+kdtydy3+tiV9cmJv0u1SM2NpwjV85H44xAQWW2zcRBHP0ZDriciHw09qQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-x64-musl": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.8.tgz",
"integrity": "sha512-WwCiAge27ZOEu7NRx5NFjpCAkGcUGHGtoROBY4ElRYE0Tp3DfuzWU06qSu6JBQPlzhTTBN29X2/kGP8iRUwqnQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1220,12 +724,6 @@
"node": ">=8"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1273,15 +771,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/await-to-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
"integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1342,12 +831,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bmp-ts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
"integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -1796,11 +1279,6 @@
"node": ">= 0.6"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -1903,24 +1381,6 @@
"node": ">=16"
}
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2067,16 +1527,6 @@
"node": ">= 0.4"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -2232,41 +1682,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/image-q/node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2364,56 +1779,6 @@
"node": ">=0.12.0"
}
},
"node_modules/jimp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
"integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/diff": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-gif": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-blur": "1.6.1",
"@jimp/plugin-circle": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-contain": "1.6.1",
"@jimp/plugin-cover": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-displace": "1.6.1",
"@jimp/plugin-dither": "1.6.1",
"@jimp/plugin-fisheye": "1.6.1",
"@jimp/plugin-flip": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/plugin-mask": "1.6.1",
"@jimp/plugin-print": "1.6.1",
"@jimp/plugin-quantize": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/plugin-rotate": "1.6.1",
"@jimp/plugin-threshold": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/libsodium": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -2715,12 +2080,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2751,34 +2110,6 @@
"wrappy": "1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-bmfont-ascii": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
"license": "MIT"
},
"node_modules/parse-bmfont-binary": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
"license": "MIT"
},
"node_modules/parse-bmfont-xml": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
"license": "MIT",
"dependencies": {
"xml-parse-from-string": "^1.0.0",
"xml2js": "^0.5.0"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
@@ -2828,27 +2159,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/play-audio": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -2867,15 +2177,6 @@
"node": ">=16.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -3092,15 +2393,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3257,15 +2549,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-xml-to-json": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz",
"integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==",
"license": "MIT",
"engines": {
"node": ">=20.12.2"
}
},
"node_modules/sodium-native": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -3360,22 +2643,6 @@
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3406,12 +2673,6 @@
"node": ">=10"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3434,24 +2695,6 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3617,18 +2860,6 @@
"node": ">= 0.8"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -3653,15 +2884,6 @@
"node": ">= 0.8"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3745,34 +2967,6 @@
}
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -3798,15 +2992,6 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

301
node_modules/.prisma/client/edge.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -141,18 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
imageModerationConfig: 'imageModerationConfig',
ticketConfig: 'ticketConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -169,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -264,230 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.RegisterApplicationNoteScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
authorId: 'authorId',
authorTag: 'authorTag',
body: 'body',
createdAt: 'createdAt'
};
exports.Prisma.AutomodStrikeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
filterKey: 'filterKey',
weight: 'weight',
reason: 'reason',
createdAt: 'createdAt'
};
exports.Prisma.StaffTaskScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
description: 'description',
status: 'status',
assigneeId: 'assigneeId',
createdBy: 'createdBy',
createdByTag: 'createdByTag',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.ModCaseScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
type: 'type',
reason: 'reason',
moderatorId: 'moderatorId',
moderatorTag: 'moderatorTag',
createdAt: 'createdAt'
};
exports.Prisma.WatchlistScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
reason: 'reason',
addedBy: 'addedBy',
addedAt: 'addedAt',
active: 'active'
};
exports.Prisma.UserBadgeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
badgeKey: 'badgeKey',
awardedAt: 'awardedAt'
};
exports.Prisma.UserActivityScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
messageCount: 'messageCount',
activeDays: 'activeDays',
lastActiveDay: 'lastActiveDay'
};
exports.Prisma.WeeklyPlanScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
title: 'title',
entries: 'entries',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
id: 'id',
planId: 'planId',
entryId: 'entryId',
userId: 'userId',
createdAt: 'createdAt'
};
exports.Prisma.PollScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
question: 'question',
options: 'options',
anonymous: 'anonymous',
closesAt: 'closesAt',
closed: 'closed',
createdBy: 'createdBy',
createdAt: 'createdAt'
};
exports.Prisma.PollVoteScalarFieldEnum = {
id: 'id',
pollId: 'pollId',
userId: 'userId',
optionId: 'optionId',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
content: 'content',
status: 'status',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionVoteScalarFieldEnum = {
id: 'id',
suggestionId: 'suggestionId',
userId: 'userId',
value: 'value'
};
exports.Prisma.PartnerRequestScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
userTag: 'userTag',
serverName: 'serverName',
inviteCode: 'inviteCode',
memberCount: 'memberCount',
description: 'description',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt'
};
exports.Prisma.GalleryPostScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
imageUrl: 'imageUrl',
caption: 'caption',
createdAt: 'createdAt'
};
exports.Prisma.GalleryVoteScalarFieldEnum = {
id: 'id',
postId: 'postId',
userId: 'userId'
};
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
inviterId: 'inviterId',
suspicious: 'suspicious',
createdAt: 'createdAt'
};
exports.Prisma.InfoPanelScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
type: 'type',
title: 'title',
description: 'description',
items: 'items',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -522,36 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask',
ModCase: 'ModCase',
Watchlist: 'Watchlist',
UserBadge: 'UserBadge',
UserActivity: 'UserActivity',
WeeklyPlan: 'WeeklyPlan',
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
Poll: 'Poll',
PollVote: 'PollVote',
Suggestion: 'Suggestion',
SuggestionVote: 'SuggestionVote',
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent',
InfoPanel: 'InfoPanel'
EventSignup: 'EventSignup'
};
/**

31568
node_modules/.prisma/client/index.d.ts generated vendored

File diff suppressed because it is too large Load Diff

305
node_modules/.prisma/client/index.js generated vendored

File diff suppressed because one or more lines are too long

286
node_modules/.prisma/client/wasm.js generated vendored
View File

@@ -141,18 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
imageModerationConfig: 'imageModerationConfig',
ticketConfig: 'ticketConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -169,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -264,230 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.RegisterApplicationNoteScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
authorId: 'authorId',
authorTag: 'authorTag',
body: 'body',
createdAt: 'createdAt'
};
exports.Prisma.AutomodStrikeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
filterKey: 'filterKey',
weight: 'weight',
reason: 'reason',
createdAt: 'createdAt'
};
exports.Prisma.StaffTaskScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
description: 'description',
status: 'status',
assigneeId: 'assigneeId',
createdBy: 'createdBy',
createdByTag: 'createdByTag',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.ModCaseScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
type: 'type',
reason: 'reason',
moderatorId: 'moderatorId',
moderatorTag: 'moderatorTag',
createdAt: 'createdAt'
};
exports.Prisma.WatchlistScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
reason: 'reason',
addedBy: 'addedBy',
addedAt: 'addedAt',
active: 'active'
};
exports.Prisma.UserBadgeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
badgeKey: 'badgeKey',
awardedAt: 'awardedAt'
};
exports.Prisma.UserActivityScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
messageCount: 'messageCount',
activeDays: 'activeDays',
lastActiveDay: 'lastActiveDay'
};
exports.Prisma.WeeklyPlanScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
title: 'title',
entries: 'entries',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
id: 'id',
planId: 'planId',
entryId: 'entryId',
userId: 'userId',
createdAt: 'createdAt'
};
exports.Prisma.PollScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
question: 'question',
options: 'options',
anonymous: 'anonymous',
closesAt: 'closesAt',
closed: 'closed',
createdBy: 'createdBy',
createdAt: 'createdAt'
};
exports.Prisma.PollVoteScalarFieldEnum = {
id: 'id',
pollId: 'pollId',
userId: 'userId',
optionId: 'optionId',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
content: 'content',
status: 'status',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionVoteScalarFieldEnum = {
id: 'id',
suggestionId: 'suggestionId',
userId: 'userId',
value: 'value'
};
exports.Prisma.PartnerRequestScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
userTag: 'userTag',
serverName: 'serverName',
inviteCode: 'inviteCode',
memberCount: 'memberCount',
description: 'description',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt'
};
exports.Prisma.GalleryPostScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
imageUrl: 'imageUrl',
caption: 'caption',
createdAt: 'createdAt'
};
exports.Prisma.GalleryVoteScalarFieldEnum = {
id: 'id',
postId: 'postId',
userId: 'userId'
};
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
inviterId: 'inviterId',
suspicious: 'suspicious',
createdAt: 'createdAt'
};
exports.Prisma.InfoPanelScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
type: 'type',
title: 'title',
description: 'description',
items: 'items',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -522,36 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask',
ModCase: 'ModCase',
Watchlist: 'Watchlist',
UserBadge: 'UserBadge',
UserActivity: 'UserActivity',
WeeklyPlan: 'WeeklyPlan',
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
Poll: 'Poll',
PollVote: 'PollVote',
Suggestion: 'Suggestion',
SuggestionVote: 'SuggestionVote',
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent',
InfoPanel: 'InfoPanel'
EventSignup: 'EventSignup'
};
/**

802
package-lock.json generated
View File

@@ -20,8 +20,6 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7",
"sodium-native": "^4.0.4"
@@ -36,16 +34,6 @@
"typescript": "^5.2.2"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -337,430 +325,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@jimp/core": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
"integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==",
"license": "MIT",
"dependencies": {
"@jimp/file-ops": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"await-to-js": "^3.0.0",
"exif-parser": "^0.1.12",
"file-type": "^21.3.3",
"mime": "3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/core/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@jimp/diff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz",
"integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==",
"license": "MIT",
"dependencies": {
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"pixelmatch": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/file-ops": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz",
"integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-bmp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz",
"integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"bmp-ts": "^1.0.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-gif": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz",
"integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"gifwrap": "^0.10.1",
"omggif": "^1.0.10"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-jpeg": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz",
"integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"jpeg-js": "^0.4.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-png": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz",
"integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"pngjs": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-tiff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz",
"integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"utif2": "^4.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz",
"integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blur": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz",
"integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-circle": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz",
"integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-color": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz",
"integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"tinycolor2": "^1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-contain": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz",
"integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-cover": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz",
"integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-crop": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz",
"integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-displace": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz",
"integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-dither": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz",
"integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-fisheye": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz",
"integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-flip": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz",
"integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-hash": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz",
"integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"any-base": "^1.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-mask": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz",
"integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-print": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz",
"integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/types": "1.6.1",
"parse-bmfont-ascii": "^1.0.6",
"parse-bmfont-binary": "^1.0.6",
"parse-bmfont-xml": "^1.1.6",
"simple-xml-to-json": "^1.2.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-quantize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz",
"integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz",
"integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-rotate": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz",
"integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-threshold": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz",
"integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/types": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz",
"integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1154,52 +718,6 @@
"node": ">= 10"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1497,12 +1015,6 @@
"node": ">=8"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1550,15 +1062,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/await-to-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
"integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1619,12 +1122,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bmp-ts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
"integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -2073,11 +1570,6 @@
"node": ">= 0.6"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -2180,24 +1672,6 @@
"node": ">=16"
}
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2281,6 +1755,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2358,16 +1833,6 @@
"node": ">= 0.4"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -2523,41 +1988,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/image-q/node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2655,56 +2085,6 @@
"node": ">=0.12.0"
}
},
"node_modules/jimp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
"integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/diff": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-gif": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-blur": "1.6.1",
"@jimp/plugin-circle": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-contain": "1.6.1",
"@jimp/plugin-cover": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-displace": "1.6.1",
"@jimp/plugin-dither": "1.6.1",
"@jimp/plugin-fisheye": "1.6.1",
"@jimp/plugin-flip": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/plugin-mask": "1.6.1",
"@jimp/plugin-print": "1.6.1",
"@jimp/plugin-quantize": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/plugin-rotate": "1.6.1",
"@jimp/plugin-threshold": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/libsodium": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -3006,12 +2386,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3042,34 +2416,6 @@
"wrappy": "1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-bmfont-ascii": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
"license": "MIT"
},
"node_modules/parse-bmfont-binary": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
"license": "MIT"
},
"node_modules/parse-bmfont-xml": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
"license": "MIT",
"dependencies": {
"xml-parse-from-string": "^1.0.0",
"xml2js": "^0.5.0"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
@@ -3119,27 +2465,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/play-audio": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -3158,15 +2483,6 @@
"node": ">=16.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -3383,15 +2699,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3548,15 +2855,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-xml-to-json": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz",
"integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==",
"license": "MIT",
"engines": {
"node": ">=20.12.2"
}
},
"node_modules/sodium-native": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -3651,22 +2949,6 @@
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3697,12 +2979,6 @@
"node": ">=10"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3725,24 +3001,6 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3908,18 +3166,6 @@
"node": ">= 0.8"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -3944,15 +3190,6 @@
"node": ">= 0.8"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4036,34 +3273,6 @@
}
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -4089,15 +3298,6 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -7,15 +7,14 @@
"schema": "src/database/schema.prisma"
},
"scripts": {
"build:web": "npm --prefix frontend run build",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "npm run build:web && tsc",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^2.2.0",
"@discordjs/voice": "^0.19.0",
"@discordjs/opus": "^0.9.0",
"@prisma/client": "^5.4.2",
"@snazzah/davey": "^0.1.8",
"cookie-parser": "^1.4.6",
@@ -24,12 +23,10 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7",
"sodium-native": "^4.0.4"
"sodium-native": "^4.0.4",
"ffmpeg-static": "^5.2.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.3",

View File

@@ -1,2 +0,0 @@
-- Placeholder recreated because migration was already applied in the database.
-- Schema changes are already present; this file keeps the migration timeline consistent.

View File

@@ -26,12 +26,7 @@ model GuildSettings {
birthdayConfig Json?
reactionRolesEnabled Boolean?
reactionRolesConfig Json?
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
@@ -177,6 +172,7 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
@@ -187,7 +183,7 @@ model RegisterFormField {
label String
type String
required Boolean @default(false)
order Int @default(0)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

347
readme.md
View File

@@ -1,274 +1,73 @@
# 🚀 Papo Discord Bot
<p align="center">
<img src="https://img.shields.io/badge/Discord.js-v14-5865F2?style=for-the-badge&logo=discord&logoColor=white" />
<img src="https://img.shields.io/badge/TypeScript-5.x-3178C6?style=for-the-badge&logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/Node.js-20-339933?style=for-the-badge&logo=node.js&logoColor=white" />
<img src="https://img.shields.io/badge/PostgreSQL-Prisma-2D3748?style=for-the-badge&logo=postgresql&logoColor=white" />
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" />
</p>
A modern, feature-rich Discord bot built with **discord.js v14**, **TypeScript**, **Prisma**, and **PostgreSQL**, featuring a powerful web dashboard and modular architecture.
---
# ✨ Features
## 🎫 Ticket System
- Interactive ticket panels
- Ticket claiming
- Ticket transcripts
- Support login sessions
- Slash commands (`/ticket`, `/claim`, `/close`, ...)
## 🛡️ Moderation
- Link whitelist
- Anti-Spam
- Anti-Caps
- Bad word filtering
- Comprehensive server logging
## 🎵 Music
- Play music from multiple sources
- Queue support
- Pause / Resume
- Skip
- Stop
- Loop
- Enable/Disable per server
## 👋 Community Features
- Welcome messages
- Leveling system
- Birthday reminders
- Reaction Roles
- Dynamic Voice Channels
- Event system with reminders
## 📊 Dashboard
- Discord OAuth2 Login
- Guild management
- Modular settings
- Status Page integration
- Rich Presence management
- Modern responsive interface
---
# 🛠️ Tech Stack
| Technology | Version |
|------------|---------|
| Node.js | 20+ |
| TypeScript | Latest |
| discord.js | v14 |
| Express | Latest |
| Prisma ORM | Latest |
| PostgreSQL | 15+ |
| Docker | Supported |
---
# 📦 Installation
## Local Development
Clone the repository
```bash
git clone https://github.com/yourname/papo-discord-bot.git
cd papo-discord-bot
```
Create your environment file
```bash
cp .env.example .env
```
Install dependencies
```bash
npm install
```
Generate Prisma Client
```bash
npx prisma generate --schema=src/database/schema.prisma
```
Run database migrations
```bash
npx prisma migrate dev --name init
```
Start the development server
```bash
npm run dev
```
The bot and dashboard will start on the configured **PORT** (default: `3000`).
Slash commands are automatically registered for the guilds defined in:
- `DISCORD_GUILD_IDS`
- or `DISCORD_GUILD_ID`
---
# 🐳 Docker
Start the complete development stack
```bash
docker-compose up --build
```
Build the Docker image manually
```bash
docker build -t papo-discord-bot .
```
The Docker image automatically generates the Prisma Client during the build process.
---
# ⚙️ Environment Variables
| Variable | Description |
|-----------|-------------|
| `DISCORD_TOKEN` | Discord Bot Token |
| `DISCORD_CLIENT_ID` | Discord OAuth Client ID |
| `DISCORD_CLIENT_SECRET` | Discord OAuth Secret |
| `DATABASE_URL` | PostgreSQL Connection String |
| `PORT` | Dashboard Port (default: 3000) |
| `SESSION_SECRET` | Express Session Secret |
| `DASHBOARD_BASE_URL` | Public Dashboard URL |
| `WEB_BASE_PATH` | Base path (default: `/ucp`) |
| `OWNER_IDS` | Comma-separated Bot Owners |
| `SUPPORT_ROLE_ID` | Support Role ID |
| `DISCORD_GUILD_ID(S)` | Guild(s) for command registration |
---
# 🗄️ Database
Main Prisma schema
```
src/database/schema.prisma
```
Generate Prisma Client
```bash
npx prisma generate --schema=src/database/schema.prisma
```
Create a migration
```bash
npx prisma migrate dev --name your-migration
```
### Core Models
- GuildSettings
- Ticket
- TicketSupportSession
- Event
- EventSignup
- RegisterForm
- RegisterApplication
- Birthday
- ReactionRoleSet
- Level
---
# 📜 Available Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Development Mode |
| `npm run build` | Compile TypeScript |
| `npm start` | Run Production Build |
| `npx prisma ...` | Prisma CLI |
---
# 🌐 Dashboard API
Authentication
```
/auth/discord
/auth/callback
/auth/logout
```
Protected API
```
/api/*
```
Main Endpoints
- `/api/guilds`
- `/api/settings`
- `/api/modules`
- `/api/tickets`
- `/api/events`
- `/api/reactionroles`
- `/api/birthday`
- `/api/statuspage`
Only guilds where the authenticated user has **Manage Server** permissions and where the bot is present are accessible.
---
# 🚀 Deployment
Production build
```bash
npm run build
npm start
```
or simply use Docker.
Ticket transcripts are stored in
```
./transcripts
```
When running inside Docker, mount this directory as a volume to persist transcripts.
---
# ❤️ Contributing
Contributions, feature requests, and bug reports are always welcome!
Feel free to open an Issue or submit a Pull Request.
---
# 📄 License
No license has been specified yet.
Please add an appropriate open-source license before publishing this project.
---
<p align="center">
Built with ❤️ using TypeScript, Discord.js, Prisma & PostgreSQL
</p>
# Papo Discord Bot
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
## Was drin ist
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback.
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien.
- Leveling: XP/Level pro Nachricht, /rank, toggelbar.
- Dynamische Voice: Lobby erzeugt private Voice-Channels mit Template/Userlimit.
- Birthday: /birthday + geplante Glueckwuensche mit Template/Channel.
- Reaction Roles: Verwaltung im Dashboard, Sync/Loeschen/Erstellen.
- Events: Einmalig/recurring, Reminder, Signups, Buttons.
- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard.
- Dashboard: OAuth2 (Scopes identify, guilds), zeigt nur Guilds, die der Nutzer besitzt oder mit Manage Guild/Admin-Rechten verwalten darf **und** in denen der Bot ist. Modulabhaengige Navigation.
- Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler.
## Tech-Stack
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose (App + Postgres)
## Setup (lokal, Entwicklung)
1. Repo klonen, in das Verzeichnis wechseln.
2. `cp .env.example .env` und Variablen setzen (siehe unten).
3. Dependencies installieren: `npm ci` (oder `npm install`).
4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`.
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
## Setup mit Docker
- `.dockerignore` blendet lokale node_modules/.env aus.
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
## Environment-Variablen
- `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Webserver/Dashboard, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
## Kommandos & Scripts
- `npm run dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build
- `npm start` Start aus `dist`
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`)
## Dashboard / API Kurzinfo
- Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`.
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist.
- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
## Credits/Lizenz
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.

View File

@@ -23,9 +23,7 @@ const command: SlashCommand = {
await member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
context.logging.logAction(user, 'Ban', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'ban', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gebannt', reason);
context.logging.logAction(user, 'Ban', reason);
}
};

View File

@@ -1,76 +0,0 @@
import { EmbedBuilder, SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const TYPE_LABELS: Record<string, string> = {
warn: 'Warnungen',
mute: 'Mutes',
timeout: 'Timeouts',
kick: 'Kicks',
ban: 'Bans',
tempban: 'Tempbans',
note: 'Notizen',
watchlist_add: 'Watchlist (hinzugefügt)',
watchlist_remove: 'Watchlist (entfernt)'
};
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('case')
.setDescription('Zeigt oder ergänzt die Mod-Akte eines Nutzers.')
.addSubcommand((sub) =>
sub
.setName('view')
.setDescription('Zeigt die Mod-Akte eines Nutzers.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
)
.addSubcommand((sub) =>
sub
.setName('note')
.setDescription('Fügt eine interne Notiz zur Akte hinzu.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
.addStringOption((opt) => opt.setName('body').setDescription('Notiz').setRequired(true))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
if (sub === 'note') {
const body = interaction.options.getString('body', true);
await context.modCases.addNote(interaction.guild.id, user.id, interaction.user.id, interaction.user.tag, body);
await interaction.reply({ content: `Notiz zur Akte von ${user.tag} hinzugefügt.`, ephemeral: true });
return;
}
const data = await context.modCases.getCase(interaction.guild.id, user.id);
const watched = context.watchlist.isWatched(interaction.guild.id, user.id);
const embed = new EmbedBuilder()
.setTitle(`Mod-Akte: ${user.tag}`)
.setColor(watched ? 0xeab308 : 0x7289da)
.addFields(
...Object.entries(TYPE_LABELS).map(([type, label]) => ({ name: label, value: String(data.counts[type] || 0), inline: true })),
{ name: 'Tickets erstellt', value: String(data.ticketCount), inline: true },
{ name: 'Automod-Verstöße', value: String(data.automodStrikes), inline: true },
{ name: 'Beobachtungsliste', value: watched ? 'Ja' : 'Nein', inline: true }
);
const recent = data.cases.slice(0, 10);
if (recent.length) {
embed.addFields({
name: 'Letzte Einträge',
value: recent
.map((c) => `**${TYPE_LABELS[c.type] || c.type}** — ${c.reason || 'Kein Grund'} (${c.moderatorTag}, ${c.createdAt.toLocaleDateString('de-DE')})`)
.join('\n')
.slice(0, 1024)
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export default command;

View File

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

View File

@@ -21,9 +21,7 @@ const command: SlashCommand = {
}
await member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
context.logging.logAction(user, 'Kick', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'kick', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gekickt', reason);
context.logging.logAction(user, 'Kick', reason);
}
};

View File

@@ -1,48 +0,0 @@
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('lockdown')
.setDescription('Aktiviert oder deaktiviert den Raid-Schutz/Lockdown-Modus.')
.addSubcommand((sub) =>
sub
.setName('enable')
.setDescription('Aktiviert den Lockdown-Modus.')
.addStringOption((opt) => opt.setName('reason').setDescription('Grund für den Lockdown'))
.addRoleOption((opt) => opt.setName('staff_role').setDescription('Rolle, die im Raid-Log gepingt wird'))
)
.addSubcommand((sub) => sub.setName('disable').setDescription('Hebt den Lockdown-Modus wieder auf.'))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const sub = interaction.options.getSubcommand();
if (sub === 'enable') {
if (context.lockdown.isActive(interaction.guildId!)) {
await interaction.reply({ content: 'Lockdown ist bereits aktiv.', ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
const reason = interaction.options.getString('reason') ?? undefined;
const staffRole = interaction.options.getRole('staff_role');
await context.lockdown.enable(interaction.guild, interaction.user.id, reason, staffRole?.id);
await interaction.editReply({ content: 'Lockdown wurde aktiviert. Alle Textkanäle sind für @everyone gesperrt.' });
return;
}
if (sub === 'disable') {
if (!context.lockdown.isActive(interaction.guildId!)) {
await interaction.reply({ content: 'Lockdown ist aktuell nicht aktiv.', ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
await context.lockdown.disable(interaction.guild);
await interaction.editReply({ content: 'Lockdown wurde aufgehoben.' });
}
}
};
export default command;

View File

@@ -23,9 +23,7 @@ const command: SlashCommand = {
}
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
context.logging.logAction(user, 'Mute', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'mute', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gemutet', reason);
context.logging.logAction(user, 'Mute', reason);
}
};

View File

@@ -1,65 +0,0 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
function formatList(items: { id: string; name: string }[], max = 10): string {
if (!items.length) return 'Keine';
const shown = items.slice(0, max).map((i) => i.name);
const rest = items.length - shown.length;
return shown.join(', ') + (rest > 0 ? ` (+${rest} weitere)` : '');
}
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('permissions')
.setDescription('Analysiert Rollen und Berechtigungen auf Sicherheitsrisiken.')
.addSubcommand((sub) => sub.setName('scan').setDescription('Startet die Rechte-Analyse.'))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
await interaction.deferReply({ ephemeral: true });
const result = await context.permissionScan.scan(interaction.guild);
const embed = new EmbedBuilder()
.setTitle('🔐 Rechte-Scan')
.setColor(result.tooManyAdmins ? 0xdc2626 : 0xf97316)
.setDescription(`Analyse für **${interaction.guild.name}**`)
.addFields(
{ name: 'Admin-Rollen', value: formatList(result.adminRoles), inline: false },
{ name: 'Rollen, die bannen können', value: formatList(result.banRoles), inline: false },
{
name: 'Bots mit gefährlichen Rechten',
value: result.dangerousBots.length
? result.dangerousBots.slice(0, 10).map((b) => `${b.tag}: ${b.perms.join(', ')}`).join('\n')
: 'Keine',
inline: false
},
{ name: 'Öffentliche Kanäle', value: formatList(result.publicChannels), inline: false },
{ name: '@everyone kann schreiben', value: formatList(result.everyoneCanSendChannels), inline: false },
{ name: 'Leere Rollen', value: formatList(result.emptyRoles), inline: false },
{
name: 'Doppelte Rollen (gleiche Rechte)',
value: result.duplicateRoleGroups.length
? result.duplicateRoleGroups.slice(0, 5).map((g) => g.map((r) => r.name).join(' = ')).join('\n')
: 'Keine',
inline: false
},
{ name: 'Nutzlose Rollen (keine Rechte, keine Mitglieder)', value: formatList(result.uselessRoles), inline: false },
{
name: '⚠️ Zu viele Admin-Rechte?',
value: result.tooManyAdmins
? `Ja — ${result.adminRoles.length} Admin-Rolle(n), ${result.adminMemberCount} Mitglied(er) mit Administrator.`
: 'Nein, sieht unauffällig aus.',
inline: false
}
)
.setTimestamp();
context.branding.applyFooter(embed, interaction.guild.id);
await interaction.editReply({ embeds: [embed] });
}
};
export default command;

Some files were not shown because too many files have changed in this diff Show More