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 .env
node_modules node_modules
debug.log
dist/

View File

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

View File

@@ -4,12 +4,12 @@ services:
app: app:
build: build:
context: . context: .
dockerfile: dockerfile dockerfile: Dockerfile
image: papo-app:latest image: papo-app:latest
working_dir: /usr/src/app working_dir: /usr/src/app
env_file: env_file:
- .env - .env
command: sh -c "npx prisma migrate deploy --schema=src/database/schema.prisma && npm run dev" command: sh -c "npm run dev"
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: 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 DATABASE_URL=postgresql://user:pass@localhost:5432/papo?schema=public
ENV PRISMA_IGNORE_ENV_LOAD=true ENV PRISMA_IGNORE_ENV_LOAD=true
# Install backend dependencies # Install dependencies (inkl. dev)
COPY package*.json ./ COPY package*.json ./
RUN npm ci --include=dev 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) # Ensure prisma CLI available globally (avoids path issues)
RUN npm install -g prisma@5.4.2 RUN npm install -g prisma@5.4.2
# Copy source
COPY . .
# Generate Prisma client (explicit schema path) # Generate Prisma client (explicit schema path)
RUN prisma generate --schema=src/database/schema.prisma 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 # Optional: show versions in build log
RUN node -v && npm -v && npx prisma -v 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, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "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": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -274,430 +264,6 @@
"scripts/actions/documentation" "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": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "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" "@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", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.8.tgz", "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.8.tgz",
"integrity": "sha512-yghgG7iXZUHy734Cq3PcgrbRnLhhB233JNTX5VPRxRqdwFAg2MzAJ2iSWpP12K6hSqKq9hw0sdt8CNpr0mEXjQ==", "integrity": "sha512-JKIco1miwtM4NgVwU/H9TJdUaSlJ+kdtydy3+tiV9cmJv0u1SM2NpwjV85H44xAQWW2zcRBHP0ZDriciHw09qQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "win32"
], ],
"engines": { "engines": {
"node": ">= 10" "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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1220,12 +724,6 @@
"node": ">=8" "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": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1273,15 +771,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1342,12 +831,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -1796,11 +1279,6 @@
"node": ">= 0.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": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -1903,24 +1381,6 @@
"node": ">=16" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2067,16 +1527,6 @@
"node": ">= 0.4" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -2232,41 +1682,6 @@
"node": ">=0.10.0" "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": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2364,56 +1779,6 @@
"node": ">=0.12.0" "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": { "node_modules/libsodium": {
"version": "0.7.15", "version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz", "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -2715,12 +2080,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2751,34 +2110,6 @@
"wrappy": "1" "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": { "node_modules/parse-cache-control": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", "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" "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": { "node_modules/play-audio": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz", "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -2867,15 +2177,6 @@
"node": ">=16.0.0" "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": { "node_modules/prism-media": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -3092,15 +2393,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3257,15 +2549,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "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": { "node_modules/sodium-native": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -3360,22 +2643,6 @@
"node": ">=0.10.0" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3406,12 +2673,6 @@
"node": ">=10" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3434,24 +2695,6 @@
"node": ">=0.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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3617,18 +2860,6 @@
"node": ">= 0.8" "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": { "node_modules/undici": {
"version": "6.21.3", "version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -3653,15 +2884,6 @@
"node": ">= 0.8" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -3798,15 +2992,6 @@
"engines": { "engines": {
"node": ">=6" "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', reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig', reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled', 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', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -169,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status', status: 'status',
claimedBy: 'claimedBy', claimedBy: 'claimedBy',
transcript: 'transcript', 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', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -264,230 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt' 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 = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -522,36 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings', GuildSettings: 'GuildSettings',
Ticket: 'Ticket', Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level', Level: 'Level',
TicketSupportSession: 'TicketSupportSession', TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday', Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet', ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event', Event: 'Event',
EventSignup: 'EventSignup', 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'
}; };
/** /**

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', reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig', reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled', 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', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -169,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status', status: 'status',
claimedBy: 'claimedBy', claimedBy: 'claimedBy',
transcript: 'transcript', 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', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -264,230 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt' 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 = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -522,36 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings', GuildSettings: 'GuildSettings',
Ticket: 'Ticket', Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level', Level: 'Level',
TicketSupportSession: 'TicketSupportSession', TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday', Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet', ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event', Event: 'Event',
EventSignup: 'EventSignup', 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'
}; };
/** /**

802
package-lock.json generated
View File

@@ -20,8 +20,6 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7", "play-dl": "^1.9.7",
"sodium-native": "^4.0.4" "sodium-native": "^4.0.4"
@@ -36,16 +34,6 @@
"typescript": "^5.2.2" "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": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -337,430 +325,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1154,52 +718,6 @@
"node": ">= 10" "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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1497,12 +1015,6 @@
"node": ">=8" "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": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1550,15 +1062,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1619,12 +1122,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -2073,11 +1570,6 @@
"node": ">= 0.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": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -2180,24 +1672,6 @@
"node": ">=16" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2281,6 +1755,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2358,16 +1833,6 @@
"node": ">= 0.4" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -2523,41 +1988,6 @@
"node": ">=0.10.0" "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": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2655,56 +2085,6 @@
"node": ">=0.12.0" "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": { "node_modules/libsodium": {
"version": "0.7.15", "version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz", "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -3006,12 +2386,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3042,34 +2416,6 @@
"wrappy": "1" "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": { "node_modules/parse-cache-control": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", "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" "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": { "node_modules/play-audio": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz", "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -3158,15 +2483,6 @@
"node": ">=16.0.0" "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": { "node_modules/prism-media": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -3383,15 +2699,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3548,15 +2855,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "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": { "node_modules/sodium-native": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -3651,22 +2949,6 @@
"node": ">=0.10.0" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3697,12 +2979,6 @@
"node": ">=10" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3725,24 +3001,6 @@
"node": ">=0.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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3908,18 +3166,6 @@
"node": ">= 0.8" "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": { "node_modules/undici": {
"version": "6.21.3", "version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -3944,15 +3190,6 @@
"node": ">= 0.8" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -4089,15 +3298,6 @@
"engines": { "engines": {
"node": ">=6" "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" "schema": "src/database/schema.prisma"
}, },
"scripts": { "scripts": {
"build:web": "npm --prefix frontend run build",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "npm run build:web && tsc", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^2.2.0", "@discordjs/rest": "^2.2.0",
"@discordjs/voice": "^0.19.0", "@discordjs/voice": "^0.19.0",
"@discordjs/opus": "^0.9.0",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.4.2",
"@snazzah/davey": "^0.1.8", "@snazzah/davey": "^0.1.8",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@@ -24,12 +23,10 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7", "play-dl": "^1.9.7",
"sodium-native": "^4.0.4" "sodium-native": "^4.0.4",
"ffmpeg-static": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.3", "@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

@@ -9,31 +9,26 @@ datasource db {
} }
model GuildSettings { model GuildSettings {
guildId String @id guildId String @id
welcomeChannelId String? welcomeChannelId String?
logChannelId String? logChannelId String?
automodEnabled Boolean? automodEnabled Boolean?
automodConfig Json? automodConfig Json?
levelingEnabled Boolean? levelingEnabled Boolean?
ticketsEnabled Boolean? ticketsEnabled Boolean?
musicEnabled Boolean? musicEnabled Boolean?
statuspageEnabled Boolean? statuspageEnabled Boolean?
statuspageConfig Json? statuspageConfig Json?
dynamicVoiceEnabled Boolean? dynamicVoiceEnabled Boolean?
dynamicVoiceConfig Json? dynamicVoiceConfig Json?
supportLoginConfig Json? supportLoginConfig Json?
birthdayEnabled Boolean? birthdayEnabled Boolean?
birthdayConfig Json? birthdayConfig Json?
reactionRolesEnabled Boolean? reactionRolesEnabled Boolean?
reactionRolesConfig Json? reactionRolesConfig Json?
eventsEnabled Boolean? eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
registerEnabled Boolean? updatedAt DateTime @updatedAt
registerConfig Json? createdAt DateTime @default(now())
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
} }
model Ticket { model Ticket {
@@ -177,17 +172,18 @@ model RegisterForm {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
fields RegisterFormField[] fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive]) @@index([guildId, isActive])
} }
model RegisterFormField { model RegisterFormField {
id String @id @default(cuid()) id String @id @default(cuid())
formId String formId String
label String label String
type String type String
required Boolean @default(false) required Boolean @default(false)
order Int @default(0) sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade) form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
} }

347
readme.md
View File

@@ -1,274 +1,73 @@
# 🚀 Papo Discord Bot # Papo Discord Bot
<p align="center"> Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
<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" /> ## Was drin ist
<img src="https://img.shields.io/badge/Node.js-20-339933?style=for-the-badge&logo=node.js&logoColor=white" /> - Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
<img src="https://img.shields.io/badge/PostgreSQL-Prisma-2D3748?style=for-the-badge&logo=postgresql&logoColor=white" /> - Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /> - Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
</p> - 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.
A modern, feature-rich Discord bot built with **discord.js v14**, **TypeScript**, **Prisma**, and **PostgreSQL**, featuring a powerful web dashboard and modular architecture. - 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.
# ✨ Features - Events: Einmalig/recurring, Reminder, Signups, Buttons.
- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard.
## 🎫 Ticket System - 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.
- Interactive ticket panels - Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler.
- Ticket claiming
- Ticket transcripts ## Tech-Stack
- Support login sessions - Node.js 20 (Docker-Basis), TypeScript (CommonJS)
- Slash commands (`/ticket`, `/claim`, `/close`, ...) - discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
## 🛡️ Moderation - Dockerfile + docker-compose (App + Postgres)
- Link whitelist
- Anti-Spam ## Setup (lokal, Entwicklung)
- Anti-Caps 1. Repo klonen, in das Verzeichnis wechseln.
- Bad word filtering 2. `cp .env.example .env` und Variablen setzen (siehe unten).
- Comprehensive server logging 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`.
## 🎵 Music 5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
- Play music from multiple sources 6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
- Queue support
- Pause / Resume ## Setup mit Docker
- Skip - `.dockerignore` blendet lokale node_modules/.env aus.
- Stop - Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
- Loop - Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
- Enable/Disable per server
## Environment-Variablen
## 👋 Community Features - `DISCORD_TOKEN` (Pflicht, Bot Token)
- Welcome messages - `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
- Leveling system - `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
- Birthday reminders - `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- Reaction Roles - `DATABASE_URL` (Pflicht, Postgres)
- Dynamic Voice Channels - `PORT` (Webserver/Dashboard, default 3000)
- Event system with reminders - `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
## 📊 Dashboard - `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
- Discord OAuth2 Login - `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
- Guild management - `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
- Modular settings
- Status Page integration ## Datenbank / Prisma
- Rich Presence management - Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
- Modern responsive interface - 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
# 🛠️ Tech Stack - `npm run dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build
| Technology | Version | - `npm start` Start aus `dist`
|------------|---------| - Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`)
| Node.js | 20+ |
| TypeScript | Latest | ## Dashboard / API Kurzinfo
| discord.js | v14 | - Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`.
| Express | Latest | - `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist.
| Prisma ORM | Latest | - Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
| PostgreSQL | 15+ |
| Docker | Supported | ## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
--- - Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
# 📦 Installation ## Credits/Lizenz
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.
## 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>

View File

@@ -23,9 +23,7 @@ const command: SlashCommand = {
await member.ban({ reason }).catch(() => null); await member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` }); await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
context.logging.logAction(user, 'Ban', reason, interaction.guild); context.logging.logAction(user, 'Ban', reason);
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);
} }
}; };

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

View File

@@ -21,9 +21,7 @@ const command: SlashCommand = {
} }
await member.kick(reason); await member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` }); await interaction.reply({ content: `${user.tag} wurde gekickt.` });
context.logging.logAction(user, 'Kick', reason, interaction.guild); context.logging.logAction(user, 'Kick', reason);
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);
} }
}; };

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 member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
context.logging.logAction(user, 'Mute', reason, interaction.guild); context.logging.logAction(user, 'Mute', reason);
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);
} }
}; };

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