Compare commits

..

4 Commits

Author SHA1 Message Date
Pascal Prießnitz
e21d9e11b6 [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 18:53:48 +01:00
Pascal Prießnitz
5681e14f8d [deploy] restore dashboard after register addition 2025-12-03 18:22:25 +01:00
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
89 changed files with 3709 additions and 16908 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,7 +4,7 @@ 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:

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,61 +0,0 @@
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 { Admin } from './pages/Admin';
function AppContent() {
const { guilds, currentGuildId, section } = useApp();
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 'admin': return <Admin />;
default: return <Dashboard />;
}
}
function App() {
return (
<AppLayout>
<AppContent />
</AppLayout>
);
}
export default App;

View File

@@ -1,185 +0,0 @@
@import "tailwindcss";
@import "@heroui/styles";
@custom-variant dark (&:is(.dark *));
:root {
--app-bg: #110f0d;
--app-bg-soft: #181513;
--app-panel: #1d1a1f;
--app-panel-strong: #221e24;
--app-panel-muted: #171419;
--app-border: rgba(255, 255, 255, 0.08);
--app-border-strong: rgba(255, 166, 77, 0.22);
--app-text: #f6f2ee;
--app-text-muted: #b8aca1;
--app-accent: #ff8a3d;
--app-accent-strong: #ff6a00;
--app-accent-soft: rgba(255, 138, 61, 0.14);
--app-accent-glow: rgba(255, 138, 61, 0.28);
}
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:
radial-gradient(circle at top left, rgba(255, 106, 0, 0.12), transparent 22%),
radial-gradient(circle at top right, rgba(255, 138, 61, 0.08), transparent 18%),
linear-gradient(180deg, #0f0d0c 0%, #141110 100%);
color: var(--app-text);
}
* {
box-sizing: border-box;
}
button {
cursor: pointer;
}
input,
textarea,
select {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
color: inherit;
font: inherit;
padding: 0.85rem 1rem;
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
}
textarea {
min-height: 120px;
resize: vertical;
}
input::placeholder,
textarea::placeholder {
color: rgba(255, 255, 255, 0.42);
}
input:hover,
textarea:hover,
select:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.07);
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--app-accent);
box-shadow: 0 0 0 3px rgba(255, 138, 61, 0.16);
background: rgba(255, 255, 255, 0.08);
}
label {
display: inline-block;
margin-bottom: 0.45rem;
font-size: 0.95rem;
font-weight: 600;
}
.surface-card {
background: linear-gradient(180deg, rgba(31, 27, 33, 0.96), rgba(23, 20, 25, 0.96));
border: 1px solid var(--app-border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.surface-card-strong {
background: linear-gradient(180deg, rgba(36, 30, 24, 0.98), rgba(25, 21, 18, 0.98));
border: 1px solid var(--app-border-strong);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 18px 50px rgba(0, 0, 0, 0.28);
}
.surface-card-muted {
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.accent-text {
color: var(--app-accent);
}
.accent-chip {
background: var(--app-accent-soft);
color: #ffb37f;
border: 1px solid rgba(255, 138, 61, 0.18);
}
.accent-button {
background: linear-gradient(135deg, var(--app-accent) 0%, var(--app-accent-strong) 100%);
color: white;
border: 1px solid rgba(255, 166, 77, 0.45);
box-shadow: 0 10px 24px rgba(255, 106, 0, 0.18);
}
.accent-button:hover {
filter: brightness(1.06);
}
.accent-button-soft {
background: var(--app-accent-soft);
color: #ffbc8e;
border: 1px solid rgba(255, 138, 61, 0.18);
}
.ghost-button {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.09);
color: var(--app-text);
}
.nav-button {
color: var(--app-text-muted);
}
.nav-button:hover {
background: rgba(255, 138, 61, 0.08);
color: var(--app-text);
}
.nav-button-active {
background: linear-gradient(135deg, rgba(255, 138, 61, 0.22), rgba(255, 106, 0, 0.24));
color: white;
border: 1px solid rgba(255, 138, 61, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.sidebar-shell {
background: linear-gradient(180deg, rgba(16, 14, 13, 0.98), rgba(18, 15, 14, 0.98));
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.brand-mark {
background: linear-gradient(135deg, var(--app-accent) 0%, var(--app-accent-strong) 100%);
box-shadow: 0 12px 30px rgba(255, 106, 0, 0.22);
}
.section-subtle {
color: var(--app-text-muted);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
}

View File

@@ -1,39 +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 items-center justify-center">
<Spinner color="primary" label="Dashboard wird geladen..." size="lg" />
</div>
);
}
if (!guilds.length) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-default-500">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,88 +0,0 @@
import { Button, Chip, Tooltip, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Avatar } from '@heroui/react';
import { Moon, Sun, ChevronDown, LogOut, Settings } from 'lucide-react';
import { useTheme } from '../../hooks/useTheme';
import { useApp } from '../../context/AppContext';
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 section-subtle min-w-0">
<button className="hover:text-white transition-colors" onClick={() => setSection('overview')}>
Dashboard
</button>
{section !== 'overview' && (
<>
<span>/</span>
<span className="accent-text font-semibold truncate">{navLabels[section] || section}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{statusMessage && (
<Chip size="sm" variant="flat" className="accent-chip max-w-[220px]">
<span className="truncate">{statusMessage}</span>
</Chip>
)}
<Tooltip content={dark ? 'Helles Design' : 'Dunkles Design'} placement="bottom">
<Button isIconOnly radius="lg" size="sm" variant="light" className="ghost-button" onPress={toggle}>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</Tooltip>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button className="ghost-button gap-2" radius="lg" size="sm" variant="light">
<Avatar name={user?.username} size="sm" className="size-6" />
<span className="hidden sm:inline text-sm">{user?.username}</span>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="User Menu">
<DropdownItem key="settings" startContent={<Settings size={14} />} onPress={() => setSection('settings')}>
Einstellungen
</DropdownItem>
<DropdownItem key="logout" startContent={<LogOut size={14} />} color="danger" onPress={handleLogout}>
Abmelden
</DropdownItem>
</DropdownMenu>
</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,189 +0,0 @@
import { useState } from 'react';
import {
Avatar, Button, Card, CardContent, ScrollShadow, Tooltip,
Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, Music, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
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} /> },
]
},
{
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: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
]
},
{
label: 'Funktionen',
items: [
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={18} /> },
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
]
},
{
label: 'System',
items: [
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
]
}
];
export function Sidebar() {
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout } = useApp();
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={`sidebar-shell flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-20' : 'w-72'}`}>
<div className={`flex items-center gap-3 px-4 pt-4 pb-3 ${collapsed ? 'justify-center' : ''}`}>
<div className="brand-mark flex size-10 items-center justify-center rounded-2xl font-black text-white">P</div>
{!collapsed && (
<div className="min-w-0">
<div className="text-base font-bold">Papo</div>
<div className="text-[10px] uppercase tracking-widest section-subtle">Dashboard</div>
</div>
)}
</div>
<div className="px-3 pb-2">
<Select
aria-label="Guild auswaehlen"
selectedKey={currentGuildId}
onSelectionChange={(key) => {
if (typeof key === 'string') setCurrentGuildId(key);
}}
>
<SelectTrigger>
<SelectValue />
</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-4">
{navGroups.map((group) => (
<div key={group.label}>
{!collapsed && (
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] section-subtle">
{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} content={collapsed ? item.label : ''} placement="right" offset={8}>
<Button
className={`nav-button h-10 justify-start gap-3 px-3 font-medium ${isActive ? 'nav-button-active' : ''}`}
radius="lg"
variant="light"
size="sm"
startContent={item.icon}
onPress={() => setSection(item.key)}
>
{!collapsed && item.label}
</Button>
</Tooltip>
);
})}
</div>
</div>
))}
{user?.isAdmin && (
<div>
{!collapsed && (
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] section-subtle">
Admin
</div>
)}
<Tooltip content={collapsed ? 'Admin' : ''} placement="right" offset={8}>
<Button
className={`nav-button h-10 justify-start gap-3 px-3 font-medium ${section === 'admin' ? 'nav-button-active' : ''}`}
radius="lg"
variant="light"
size="sm"
startContent={<Wrench size={18} />}
onPress={() => setSection('admin')}
>
{!collapsed && 'Admin'}
</Button>
</Tooltip>
</div>
)}
</nav>
</ScrollShadow>
<div className="p-3 flex flex-col gap-2">
<Button
isIconOnly
className="ghost-button w-full"
radius="lg"
size="sm"
variant="light"
onPress={() => setCollapsed((c) => !c)}
>
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
</Button>
<Card className="surface-card">
<CardContent className={`flex items-center gap-3 p-2 ${collapsed ? 'justify-center' : ''}`}>
<Avatar name={user?.username} size="sm" className="shrink-0" />
{!collapsed && (
<>
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-semibold">{user?.username}</div>
<div className="text-[10px] section-subtle">Angemeldet</div>
</div>
<Tooltip content="Abmelden" placement="top">
<Button isIconOnly radius="lg" size="sm" variant="light" className="ghost-button" onPress={handleLogout}>
<LogOut size={14} />
</Button>
</Tooltip>
</>
)}
</CardContent>
</Card>
</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-small text-default-500">Bot-Dashboard Verwaltung</p>
</div>
<div className="flex items-center gap-3">
{statusMessage && (
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
)}
<Button isIconOnly radius="lg" size="sm" variant="light" 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="primary" size="sm" variant="flat" startContent={icon}>
{label}
</Chip>
<div>
<div className="text-2xl font-black">{value}</div>
</div>
</div>
</CardContent>
</Card>
);
}

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="border border-default-100 bg-default-50/10">
<CardContent className="flex flex-col items-center gap-3 py-8">
<div className="flex size-12 items-center justify-center rounded-full bg-default-100/50 text-default-400">
{icon || <Inbox size={24} />}
</div>
<p className="text-small text-default-400">{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-100/30 bg-danger-50/10">
<CardContent className="flex flex-col items-center gap-3 py-8">
<div className="flex size-12 items-center justify-center rounded-full bg-danger-500/10 text-danger-400">
<AlertTriangle size={24} />
</div>
<p className="text-small text-default-500">{message}</p>
{onRetry && (
<button className="flex items-center gap-1 text-tiny text-primary-400 hover:text-primary-300 transition-colors" 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 } 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} className="animate-pulse border border-default-100 bg-default-50/30">
<CardContent className="p-5">
<div className="h-4 w-3/4 rounded-lg bg-default-200/50" />
{i < 2 && <div className="mt-3 h-3 w-1/2 rounded-lg bg-default-200/30" />}
</CardContent>
</Card>
))}
</div>
);
}

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="surface-card-strong">
<CardHeader className="flex items-start justify-between gap-4">
<div className="min-w-0 flex flex-col gap-1">
<CardTitle>{title}</CardTitle>
{subtitle && <CardDescription className="section-subtle">{subtitle}</CardDescription>}
</div>
{action && <div className="shrink-0">{action}</div>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}

View File

@@ -1,33 +0,0 @@
import { Card, CardContent, Chip } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
icon: ReactNode;
label: string;
value: string | number;
trend?: string;
color?: 'primary' | 'success' | 'warning' | 'danger' | 'default';
};
export function StatCard({ icon, label, value, trend, color = 'primary' }: Props) {
const chipColor = color === 'default' ? 'default' : color;
return (
<Card className="surface-card">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<Chip color={chipColor} size="sm" variant="flat" startContent={icon} className={color === 'primary' ? 'accent-chip' : undefined}>
{label}
</Chip>
{trend && (
<span className={`text-tiny font-medium ${trend.startsWith('+') ? 'text-success-400' : trend.startsWith('-') ? 'text-danger-400' : 'text-default-400'}`}>
{trend}
</span>
)}
</div>
<div className="text-2xl font-bold tracking-tight">{value}</div>
<div className="text-tiny section-subtle">{label}</div>
</CardContent>
</Card>
);
}

View File

@@ -1,511 +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
} 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[] };
};
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;
};
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: '', 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 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', '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] = 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)}`)
]);
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 || []);
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, , logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
} 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.split('\n').map((l) => l.trim()).filter(Boolean)
.map((line) => { const p = line.split('|').map((s) => s.trim()); return { emoji: p[0], roleId: p[1], label: p[2], description: p[3] }; })
.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);
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);
}
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,
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,
}}>
{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,71 +0,0 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Wrench, Activity, Clock, Server, Terminal } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
export function Admin() {
const { user, admin } = useApp();
if (!user?.isAdmin) return null;
return (
<SectionCard title="Admin" subtitle="Bot-weite <20>bersichten">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bot Overview</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<Server size={14} className="text-primary-400" />
<span className="text-default-500">Guilds</span>
</div>
<span className="font-semibold">{admin.overview?.guilds ?? '-'}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<Activity size={14} className="text-success-400" />
<span className="text-default-500">Aktive Guilds (24h)</span>
</div>
<span className="font-semibold">{admin.overview?.activeGuilds ?? '-'}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<Clock size={14} className="text-warning-400" />
<span className="text-default-500">Uptime</span>
</div>
<span className="font-semibold">{admin.overview?.uptime ?? '-'}</span>
</div>
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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="flat" color="warning">
{(admin.logs || []).length} Eintr<EFBFBD>ge
</Chip>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(admin.logs || []).length ? (admin.logs || []).slice(0, 20).map((log, i) => (
<div key={i} className="flex items-start gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<Terminal size={14} className="mt-0.5 text-default-400 shrink-0" />
<div className="min-w-0">
<p className="text-default-500">{log.message || '-'}</p>
<p className="text-tiny text-default-400 mt-0.5">{formatDate(log.timestamp)}</p>
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
<Terminal size={20} />
Keine Logs
</div>
)}
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,85 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { Shield, Filter, Link, Ban, AlertTriangle, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
export function Automod() {
const { settings, setSettings, saveSettingsPayload } = useApp();
return (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Filter konfigurieren</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<Switch isSelected={settings.automodEnabled !== false} onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}>
<div className="flex items-center gap-2">
<Shield size={16} /> Automod aktiv
</div>
</Switch>
<div className="grid grid-cols-2 gap-3">
<Switch isSelected={settings.automodConfig?.badWordFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), badWordFilter: v } }))}>
<div className="flex items-center gap-2"><Ban size={14} /> Bad-Word-Filter</div>
</Switch>
<Switch isSelected={settings.automodConfig?.linkFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkFilter: v } }))}>
<div className="flex items-center gap-2"><Link size={14} /> Link-Filter</div>
</Switch>
<Switch isSelected={settings.automodConfig?.spamFilter ?? false} onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), spamFilter: v } }))}>
<div className="flex items-center gap-2"><AlertTriangle size={14} /> Spam-Filter</div>
</Switch>
</div>
<TextField>
<Label>Log Channel ID</Label>
<Input
placeholder="Channel ID f<>r Logs"
value={settings.automodConfig?.logChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(settings.automodConfig?.linkWhitelist || []).join(', ')}
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkWhitelist: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) } }))}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
<Separator />
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: settings.automodConfig || {} }, 'Automod gespeichert')}>
Speichern
</Button>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card className="border border-default-100 bg-default-50/20">
<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="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<p className="text-default-500">Die Automod-Einstellungen werden nach dem Speichern sofort aktiv.</p>
</div>
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<p className="text-default-500">Bad-Word-Filter entfernt Nachrichten mit unerw<EFBFBD>nschten Begriffen.</p>
</div>
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<p className="text-default-500">Link-Filter blockiert bekannte sch<EFBFBD>dliche Domains und nicht-whitelistete Links.</p>
</div>
<div className="rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<p className="text-default-500">Spam-Filter erkennt und unterdr<EFBFBD>ckt Mehrfachnachrichten in kurzer Zeit.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,83 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Switch, 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';
export function Birthday() {
const { birthday, setBirthday, saveBirthday } = useApp();
return (
<SectionCard title="Birthday" subtitle="Geburtstags-Feature und gespeicherte Eintr<74>ge">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<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">
<Switch isSelected={birthday.config?.enabled !== false} onChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}>
<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div>
</Switch>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel f<>r Geburtstagsnachrichten"
value={birthday.config?.channelId || ''}
onChange={(e) => setBirthday((s) => ({ ...s, config: { ...s.config, channelId: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Sendezeit (Stunde)</Label>
<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) } }))}
startContent={<Clock size={16} className="text-default-400" />}
/>
</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 color="primary" startContent={<Save size={16} />} onPress={saveBirthday}>Speichern</Button>
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<Cake size={14} className="text-primary-400" />
<span className="font-medium">{entry.userId}</span>
</div>
<Chip size="sm" variant="flat" color="primary">
{String(entry.birthDate || '').replace(/^--/, '')}
</Chip>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
<CalendarDays size={20} />
Keine Eintr<EFBFBD>ge
</div>
)}
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,183 +0,0 @@
import { Card, CardContent, CardHeader, Avatar, 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';
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} />, color: 'primary' as const },
{ key: 'serverstats', label: 'Sync starten', icon: <RefreshCw size={16} />, color: 'success' as const },
{ key: 'modules', label: 'Module aktualisieren', icon: <Zap size={16} />, color: 'warning' as const },
{ key: 'settings', label: 'Einstellungen oeffnen', icon: <Settings size={16} />, color: 'default' as const },
];
return (
<div className="space-y-6">
<Card className="surface-card-strong">
<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">
<Avatar className="size-20 shrink-0" radius="lg" src={guildIconUrl(selectedGuild)} />
<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" radius="sm" size="sm" startContent={<Bot size={12} />} variant="dot">
Online
</Chip>
</div>
<div className="mt-1 text-small section-subtle">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="flat" className="accent-chip">
{key.replace('Enabled', '').replace(/([A-Z])/g, ' $1').trim()}
</Chip>
))}
</div>
</div>
</div>
<div className="flex gap-2 shrink-0">
<Chip radius="sm" size="sm" variant="flat" startContent={<Gauge size={12} />} className="accent-chip">
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 className="surface-card">
<CardHeader>
<div>
<h2 className="text-lg font-bold">Activity Bereich</h2>
<p className="mt-0.5 text-tiny text-default-400">Live Statistiken</p>
</div>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<StatCard icon={<MessageSquare size={16} />} label="Nachrichten" value={activity?.messages24h ?? 0} />
<StatCard icon={<Command size={16} />} label="Commands" value={activity?.commands24h ?? 0} color="success" />
<StatCard icon={<Shield size={16} />} label="Automod" value={activity?.automod24h ?? 0} color="warning" />
<StatCard icon={<Users size={16} />} label="Neue User" value={activity?.newUsers24h ?? 0} />
</CardContent>
</Card>
<Card className="surface-card">
<CardHeader className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">Guild Logs</h2>
<p className="mt-0.5 text-tiny text-default-400">Letzte Ereignisse</p>
</div>
<Button size="sm" variant="light" className="ghost-button" endContent={<ChevronRight size={14} />} onPress={() => setSection('settings')}>
Alle
</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="surface-card-muted">
<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-500/10 text-danger-400' :
log.level === 'warn' ? 'bg-warning-500/10 text-warning-400' :
'bg-orange-500/10 text-orange-300'
}`}>
{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="flat"
>
{(log.level || 'info').toUpperCase()}
</Chip>
<span className="text-tiny text-default-400">{formatDate(log.timestamp)}</span>
</div>
<p className="mt-1 text-small text-foreground/80">
{log.category ? <span className="text-default-500">[{log.category}] </span> : ''}{log.message || '-'}
</p>
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-6 text-center text-small text-default-400">
<Activity size={20} />
Keine Logs
</div>
)}
</ScrollShadow>
</CardContent>
</Card>
<Card className="surface-card">
<CardHeader>
<h2 className="text-lg font-bold">Quick Actions</h2>
<p className="mt-0.5 text-tiny text-default-400">Schnellzugriff</p>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{quickActions.map((action) => (
<Button
key={action.key}
color="default"
startContent={action.icon}
variant="light"
className={`justify-start font-medium ${action.color === 'default' ? 'ghost-button' : 'accent-button'}`}
onPress={() => setSection(action.key as any)}
>
{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} isPressable onPress={() => setSection(key)} className="surface-card">
<CardContent className="flex flex-col items-start gap-2">
<Chip size="sm" variant="flat" startContent={item.icon} className="accent-chip">
{item.label}
</Chip>
<div className="font-semibold text-sm">{item.label}</div>
<div className="text-tiny text-default-400">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,76 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, 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';
export function DynamicVoice() {
const { settings, setSettings, saveSettingsPayload } = useApp();
return (
<SectionCard title="Dynamic Voice" subtitle="Voice-Lobby, Template und Limits">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<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">
<Switch isSelected={settings.dynamicVoiceEnabled !== false} onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}>
<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>
</Switch>
<TextField>
<Label>Lobby Channel ID</Label>
<Input
placeholder="Channel ID der Lobby"
value={settings.dynamicVoiceConfig?.lobbyChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), lobbyChannelId: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Kategorie ID</Label>
<Input
placeholder="Kategorie f<>r neue Channels"
value={settings.dynamicVoiceConfig?.categoryId || ''}
onChange={(e) => setSettings((s) => ({ ...s, dynamicVoiceConfig: { ...(s.dynamicVoiceConfig || {}), categoryId: e.target.value } }))}
/>
</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 color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ dynamicVoiceConfig: settings.dynamicVoiceConfig || {} }, 'Dynamic Voice gespeichert')}>
Speichern
</Button>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<Mic size={16} className="text-primary-400" />
<span className="text-default-500">Benutzer erstellen eigene Voice-Channels durch Beitreten der Lobby</span>
</div>
<div className="flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<Users size={16} className="text-success-400" />
<span className="text-default-500">Channel-Owner k<EFBFBD>nnen Limits und Berechtigungen verwalten</span>
</div>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,94 +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';
export function Events() {
const { events, eventDraft, setEventDraft, saveEvent, deleteEvent } = useApp();
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} className="border border-default-100 bg-default-50/20">
<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-primary-400 shrink-0" />
<span className="font-semibold text-small truncate">{event.title}</span>
</div>
<Button color="danger" size="sm" variant="flat" startContent={<Trash2 size={14} />} onPress={() => deleteEvent(event.id)}>
L<EFBFBD>schen
</Button>
</div>
<p className="text-small text-default-400">{event.description || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-2 text-tiny text-default-500">
<Clock size={12} />
{formatDate(event.startsAt)}
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
<CalendarDays size={24} />
Keine Events
</div>
)}
</div>
</div>
<Card className="border border-default-100 bg-default-50/20">
<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 ID</Label>
<Input
placeholder="Channel f<>r Erinnerungen"
value={eventDraft.channelId}
onChange={(e) => setEventDraft((s) => ({ ...s, channelId: e.target.value }))}
/>
</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 color="primary" startContent={<Plus size={16} />} onPress={saveEvent}>
Event speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,38 +0,0 @@
import { Card, CardContent, Avatar, Button } from '@heroui/react';
import { useApp } from '../context/AppContext';
import { guildIconUrl } from '../utils/formatters';
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="mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-400 to-primary-600 text-2xl font-black text-white shadow-lg shadow-primary-500/25">
P
</div>
<h1 className="text-3xl font-bold tracking-tight">Wähle einen Server</h1>
<p className="mt-2 text-default-500">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}
isPressable
className="border border-default-100 bg-default-50/20 transition-all hover:border-primary-300 hover:shadow-lg hover:shadow-primary-500/5"
onPress={() => setCurrentGuildId(guild.id)}
>
<CardContent className="flex flex-row items-center gap-4 p-5">
<Avatar src={guildIconUrl(guild)} name={guild.name} radius="lg" size="lg" />
<div className="min-w-0">
<div className="truncate text-lg font-semibold">{guild.name}</div>
<div className="mt-0.5 text-small text-default-400">ID: {guild.id}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { Card, CardContent, Switch } from '@heroui/react';
import { Puzzle, CheckCircle, XCircle } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
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-400" />
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="surface-card-strong">
<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-small text-default-400 truncate">{module.description}</div>
)}
</div>
<Switch isSelected={module.enabled} color="warning" 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-default-400" />
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} className="surface-card">
<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-small text-default-400 truncate">{module.description}</div>
)}
</div>
<Switch isSelected={module.enabled} color="primary" 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-small text-default-400">
<Puzzle size={24} />
Keine Module verf<EFBFBD>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="primary" />
<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} className="border border-default-100 bg-default-50/20">
<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-primary-400" />
<span className="font-semibold text-small">{session.guildId}</span>
</div>
<div className="flex gap-2">
<Chip size="sm" variant="flat" startContent={<Repeat size={12} />}>
{session.loop}
</Chip>
<Chip size="sm" variant="flat" startContent={<List size={12} />}>
{session.queueLength} in Queue
</Chip>
</div>
</div>
{session.nowPlaying ? (
<div className="rounded-xl bg-default-100/50 px-4 py-3 text-small">
<span className="text-default-500">Jetzt l<EFBFBD>uft: </span>
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-primary-400 hover:underline">
{session.nowPlaying.title}
<ExternalLink size={12} />
</a>
</div>
) : (
<div className="rounded-xl bg-default-100/30 px-4 py-3 text-small text-default-400">
Keine aktive Wiedergabe
</div>
)}
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
<Music size={24} />
Keine aktiven Musik-Sessions
</div>
)}
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,81 +0,0 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { Tag, Save, Hash, List } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
export function ReactionRoles() {
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole } = useApp();
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} className="border border-default-100 bg-default-50/20">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
<Tag size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-small truncate">{set.title || 'Reaction Role'}</div>
<div className="text-tiny text-default-400 truncate">Channel: {set.channelId || '-'}</div>
</div>
<Chip size="sm" variant="flat">{(set.entries?.length || 0)} Eintr<EFBFBD>ge</Chip>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
<Tag size={24} />
Keine Sets
</div>
)}
</div>
</div>
<Card className="border border-default-100 bg-default-50/20">
<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 ID</Label>
<Input
placeholder="Channel f<>r die Nachricht"
value={reactionDraft.channelId}
onChange={(e) => setReactionDraft((s) => ({ ...s, channelId: e.target.value }))}
/>
</TextField>
<div>
<label className="block text-small font-medium mb-1">Eintr<EFBFBD>ge</label>
<TextArea
placeholder="Emoji | Role ID | Label&#10;:emoji: | 123456789 | Rolle 1&#10;:wave: | 987654321 | Rolle 2"
minRows={6}
value={reactionDraft.entries}
onChange={(e) => setReactionDraft((s) => ({ ...s, entries: e.target.value }))}
/>
<p className="mt-1 text-tiny text-default-400">
Pro Zeile: Emoji | Role ID | Label (optional) | Beschreibung (optional)
</p>
</div>
<Button color="primary" startContent={<Save size={16} />} onPress={saveReactionRole}>
Reaction Role speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,152 +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 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
export function Register() {
const {
registerForms, registerApps, registerTab, setRegisterTab,
formDraft, setFormDraft, editingFormId, setEditingFormId,
saveForm, deleteForm, sendFormPanel
} = useApp();
return (
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Antr<74>ge verwalten.">
<Tabs aria-label="Register Tabs" color="primary" selectedKey={registerTab} variant="bordered" onSelectionChange={(key) => setRegisterTab(String(key))}>
<Tab key="forms">Formulare</Tab>
<Tab key="apps">Anträge</Tab>
</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} className="border border-default-100 bg-default-50/20">
<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-primary-400 shrink-0" />
<span className="font-semibold text-small truncate">{f.name}</span>
</div>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="light" 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="light" color="danger" onPress={() => deleteForm(f.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
<p className="text-small text-default-400">{f.description || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-2 text-tiny">
<Chip size="sm" variant="flat" color={f.isActive ? 'success' : 'default'}>
{f.isActive ? 'Aktiv' : 'Inaktiv'}
</Chip>
<span className="text-default-400">{f.fields?.length || 0} Felder</span>
<Button size="sm" variant="flat" startContent={<Send size={12} />} onPress={() => sendFormPanel(f.id)}>
Panel senden
</Button>
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
<ClipboardList size={24} />
Keine Formulare
</div>
)}
</div>
</div>
<Card className="border border-default-100 bg-default-50/20">
<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 minRows={6} value={formDraft.fields} onChange={(e) => setFormDraft((s) => ({ ...s, fields: e.target.value }))} />
</TextField>
<p className="text-tiny text-default-400">
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
</p>
<div className="flex gap-2">
<Button color="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
{editingFormId && (
<Button variant="flat" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
Abbrechen
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)}
{registerTab === 'apps' && (
<div className="mt-5">
<h3 className="mb-3 text-base font-semibold">Eingegangene Antr<EFBFBD>ge ({registerApps.length})</h3>
<div className="space-y-3">
{registerApps.length ? registerApps.map((app) => (
<Card key={app.id} className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{app.username || app.userId}</div>
<Chip size="sm" variant="flat" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
{app.status}
</Chip>
</div>
<div className="text-tiny text-default-400">{formatDate(app.createdAt)}</div>
{app.answers?.length ? (
<div className="space-y-1">
{app.answers.map((a, i) => (
<div key={i} className="text-small">
<span className="text-default-500">{a.label || 'Frage'}: </span>
{a.value}
</div>
))}
</div>
) : null}
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-small text-default-400">
<ClipboardList size={24} />
Keine Antr<EFBFBD>ge
</div>
)}
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,98 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, 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';
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 className="border border-default-100 bg-default-50/20">
<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">
<Switch isSelected={statsDraft?.enabled === true} onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}>
<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>
</Switch>
<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 color="primary" startContent={<Save size={16} />} onPress={saveServerStats}>
Server Stats speichern
</Button>
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<Activity size={14} className="text-primary-400" />
<span className="font-medium">{item.label || item.key}</span>
<Chip size="sm" variant="flat">{item.type || '-'}</Chip>
</div>
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatsItem(i)}>
<Trash2 size={14} />
</Button>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
<BarChart3 size={20} />
Keine Items
</div>
)}
<Separator />
<div>
<h4 className="text-small font-semibold mb-2">Item hinzuf<EFBFBD>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 border border-default-200 bg-default-50 px-3 py-2 text-sm outline-none"
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" color="primary" startContent={<Plus size={14} />} onPress={addStatsItem}>
Hinzuf<EFBFBD>gen
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,87 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Switch, Separator, TextField, Label } from '@heroui/react';
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
export function SettingsPage() {
const { settings, setSettings, saveSettingsPayload } = useApp();
return (
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<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 ID</Label>
<Input
placeholder="Channel ID"
value={settings.welcomeChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeChannelId: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Log Channel ID</Label>
<Input
placeholder="Channel ID"
value={settings.logChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, logChannelId: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Support Role ID</Label>
<Input
placeholder="Role ID"
value={settings.supportRoleId || ''}
onChange={(e) => setSettings((s) => ({ ...s, supportRoleId: e.target.value }))}
/>
</TextField>
<Separator />
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
Speichern
</Button>
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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">
<Switch isSelected={settings.loggingConfig?.categories?.joinLeave !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), joinLeave: v } } }))}>
<div className="flex items-center gap-2"><Logs size={14} /> Join / Leave loggen</div>
</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.messageEdit !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageEdit: v } } }))}>
<div className="flex items-center gap-2"><Edit3 size={14} /> Message Edit loggen</div>
</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.messageDelete !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), messageDelete: v } } }))}>
<div className="flex items-center gap-2"><Trash2 size={14} /> Message Delete loggen</div>
</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.automodActions !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), automodActions: v } } }))}>
<div className="flex items-center gap-2"><Shield size={14} /> Automod Actions loggen</div>
</Switch>
<Switch isSelected={settings.loggingConfig?.categories?.ticketActions !== false} onChange={(v) => setSettings((s) => ({ ...s, loggingConfig: { ...(s.loggingConfig || {}), categories: { ...(s.loggingConfig?.categories || {}), ticketActions: v } } }))}>
<div className="flex items-center gap-2"><Bell size={14} /> Ticket Actions loggen</div>
</Switch>
<Separator />
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
Speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,97 +0,0 @@
import { Card, CardContent, CardHeader, Input, Button, Chip, Switch, 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';
export function Statuspage() {
const { statusDraft, setStatusDraft, saveStatuspage, statusServiceDraft, setStatusServiceDraft, addStatusService, deleteStatusService } = useApp();
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 className="border border-default-100 bg-default-50/20">
<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">
<Switch isSelected={statusDraft?.enabled !== false} onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}>
<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>
</Switch>
<TextField>
<Label>Channel ID</Label>
<Input
placeholder="Channel f<>r Status-Updates"
value={statusDraft?.channelId || ''}
onChange={(e) => setStatusDraft((s) => ({ ...(s || {}), channelId: e.target.value }))}
/>
</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 color="primary" startContent={<Save size={16} />} onPress={saveStatuspage}>
Statuspage speichern
</Button>
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<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-default-400 text-tiny truncate">{service.url || ''}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Chip size="sm" variant="flat" color={
service.status === 'operational' ? 'success' :
service.status === 'degraded' ? 'warning' :
service.status === 'down' ? 'danger' : 'default'
}>{service.status || 'unknown'}</Chip>
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatusService(service.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
<ActivityIcon size={20} />
Keine Services
</div>
)}
<Separator />
<div>
<h4 className="text-small font-semibold mb-2">Service hinzuf<EFBFBD>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" color="primary" startContent={<Plus size={14} />} onPress={addStatusService}>
Hinzuf<EFBFBD>gen
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,148 +0,0 @@
import { Avatar, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Chip, Switch, Separator, TextField, Label } from '@heroui/react';
import { LogIn, UserRound, Save, Send } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
export function SupportLogin() {
const { supportLogin, setSupportLogin, saveSupportLogin } = useApp();
return (
<SectionCard title="Support Login" subtitle="Login-Panel fuer Supporter konfigurieren">
<div className="grid gap-5 xl:grid-cols-[1fr_400px]">
<Card className="surface-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">
<Switch
isSelected={supportLogin?.config?.autoRefresh !== false}
onChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
>
Auto-Refresh aktiv
</Switch>
<TextField>
<Label>Panel Channel ID</Label>
<Input
variant="bordered"
size="lg"
placeholder="Channel ID eingeben"
value={supportLogin?.config?.panelChannelId || ''}
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: e.target.value } } : s)}
/>
</TextField>
<TextField>
<Label>Titel</Label>
<Input
variant="bordered"
size="lg"
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
variant="bordered"
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
variant="bordered"
size="lg"
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
variant="bordered"
size="lg"
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="light" className="accent-button" startContent={<Save size={16} />} onPress={saveSupportLogin}>
Speichern & Panel senden
</Button>
<Button variant="light" className="ghost-button" startContent={<Send size={16} />}>
Panel manuell senden
</Button>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<Card className="surface-card">
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>Panel in einer normalen HeroUI-Karte.</CardDescription>
</div>
</CardHeader>
<CardContent>
<Card className="surface-card-muted">
<CardHeader>
<div className="flex items-center gap-2">
<LogIn size={16} className="accent-text" />
<div>
<CardTitle>{supportLogin?.config?.title || 'Support Login'}</CardTitle>
<CardDescription>{supportLogin?.config?.description || 'Melde dich als Support an/ab.'}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm" variant="light" className="accent-button">{supportLogin?.config?.loginLabel || 'Login'}</Button>
<Button size="sm" variant="light" className="ghost-button">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
</CardContent>
</Card>
</CardContent>
</Card>
<Card className="surface-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="surface-card-muted">
<CardContent className="flex items-center gap-3">
<Avatar name={s.username?.[0]} size="sm" className="size-6" />
<span className="font-medium">{s.username || s.userId}</span>
<Chip size="sm" variant="flat" color="success" className="ml-auto">Online</Chip>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-tiny text-default-400">
<UserRound size={20} />
Keine aktiven Supporter
</div>
)}
<p className="mt-2 text-tiny text-default-400">
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
</p>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -1,382 +0,0 @@
import { 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 } 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';
export function Tickets() {
const {
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
} = useApp();
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
return (
<SectionCard title="Ticketsystem" subtitle="Ticket-Übersicht, Pipeline, SLA, Automationen und Knowledge Base.">
<Tabs aria-label="Ticket Tabs" color="primary" selectedKey={ticketTab} variant="bordered" onSelectionChange={(key) => setTicketTab(String(key))}>
<Tab key="overview">Übersicht</Tab>
<Tab key="pipeline">Pipeline</Tab>
<Tab key="sla">SLA</Tab>
<Tab key="automations">Automationen</Tab>
<Tab key="kb">Knowledge Base</Tab>
</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="primary" />
<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} className="border border-default-100 bg-default-50/20">
<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 === 'open' ? 'bg-warning-400' :
t.status === 'in-progress' ? 'bg-primary-400' :
t.status === 'waiting' ? 'bg-default-400' : 'bg-success-400'
}`} />
<span className="font-semibold text-small truncate">{t.topic || 'Ticket'}</span>
</div>
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
{t.status || 'open'}
</Chip>
</div>
<div className="flex items-center gap-2 text-tiny text-default-400">
<span>{t.category || 'Allgemein'}</span>
<span>·</span>
<span>{formatDate(t.createdAt)}</span>
{t.claimedById && (
<>
<span>·</span>
<Chip size="sm" variant="flat" color="success" className="h-5">Claimed</Chip>
</>
)}
</div>
<div className="flex gap-2">
<select
aria-label="Status"
className="w-40 rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary-400"
value=""
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
>
<option value="">Status ändern</option>
<option value="open">Open</option>
<option value="in-progress">In Progress</option>
<option value="waiting">Warten</option>
<option value="closed">Closed</option>
</select>
<Button size="sm" color="danger" variant="flat" onPress={() => closeTicket(t.id)}>Schließen</Button>
<Button size="sm" variant="flat" 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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/20 px-4 py-3">
<div className="min-w-0">
<div className="text-small font-medium truncate">{t.topic || t.id}</div>
<div className="text-tiny text-default-400">{t.category || '-'} · {formatDate(t.createdAt)}</div>
</div>
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
{t.status}
</Chip>
</div>
)) : <EmptyState message="Keine Tickets" />}
</div>
</div>
</div>
{ticketDetail && (
<Card className="border border-default-100">
<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="light" 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-small">
<div className="rounded-lg bg-default-100/40 px-3 py-2">
<span className="text-default-400">Status:</span> {ticketDetail.status}
</div>
<div className="rounded-lg bg-default-100/40 px-3 py-2">
<span className="text-default-400">Priorität:</span> {ticketDetail.priority || 'normal'}
</div>
<div className="rounded-lg bg-default-100/40 px-3 py-2">
<span className="text-default-400">Kategorie:</span> {ticketDetail.category || '-'}
</div>
</div>
<Separator />
<div>
<h4 className="mb-2 text-small 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-100/40 px-3 py-2 text-small">
<span className="text-tiny text-default-400">{msg.author?.tag || msg.authorId}: </span>
{msg.content || '(Embed)'}
</div>
)) : <p className="text-tiny text-default-400">Keine Nachrichten</p>}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
{ticketTab === 'pipeline' && (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
{[
{ key: 'neu', label: 'Neu', color: 'warning' as const },
{ key: 'in_bearbeitung', label: 'In Bearbeitung', color: 'primary' as const },
{ key: 'warten_auf_user', label: 'Warten auf User', color: 'default' as const },
{ key: 'erledigt', label: 'Erledigt', color: 'success' as const },
].map(({ key, label, color }) => (
<Card key={key} className="border border-default-100 bg-default-50/20">
<CardHeader className="px-4 pt-4 pb-0">
<div className="flex items-center gap-2">
<div className={`size-2.5 rounded-full bg-${color}-400`} />
<h3 className="text-sm font-semibold">{label}</h3>
<Chip size="sm" variant="flat" color={color}>{(pipeline[key] || []).length}</Chip>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-4">
{(pipeline[key] || []).length ? (pipeline[key] || []).map((t) => (
<div key={t.id} className="rounded-xl border border-default-100 bg-default-50/30 p-3 text-small">
<div className="font-medium truncate">{t.topic || t.id}</div>
<div className="mt-1 text-tiny text-default-400">{formatDate(t.createdAt)}</div>
<Button size="sm" variant="flat" className="mt-2 h-6 min-w-0 px-2 text-tiny" onPress={() => {
const nextStatus = key === 'neu' ? 'in-progress' : key === 'in_bearbeitung' ? 'waiting' : key === 'warten_auf_user' ? 'closed' : 'closed';
updateTicketStatus(t.id, nextStatus);
}}>
{key === 'erledigt' ? 'Schließen' : key === 'warten_auf_user' ? 'Schließen' : 'Weiter'}
</Button>
</div>
)) : <p className="text-tiny text-default-400 text-center py-4">Keine Tickets</p>}
</CardContent>
</Card>
))}
</div>
)}
{ticketTab === 'sla' && (
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-3 text-small">
<div className="flex items-center gap-2">
<UserRound size={14} className="text-default-400" />
<span className="font-medium">{row.supporterId || '-'}</span>
</div>
<div className="flex gap-3 text-tiny text-default-500">
<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-tiny text-default-400 text-center py-4">Keine Daten</p>}
</CardContent>
</Card>
<Card className="border border-default-100 bg-default-50/20">
<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="flex items-center justify-between rounded-xl border border-default-100 bg-default-50/30 px-4 py-2 text-tiny">
<span className="font-medium">{row.date || '-'}</span>
<div className="flex gap-2 text-default-500">
<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-tiny text-default-400 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} className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold text-small">{rule.name || 'Automation'}</div>
<Chip size="sm" variant="flat" color={rule.active !== false ? 'success' : 'default'}>
{rule.active !== false ? 'Aktiv' : 'Inaktiv'}
</Chip>
</div>
<div className="flex gap-2">
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
Bearbeiten
</Button>
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteAutomation(rule.id)}>
Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Keine Regeln" icon={<FileText size={24} />} />}
</div>
</div>
<div>
{automationEditDraft ? (
<Card className="border border-default-100 bg-default-50/20">
<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 color="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
<Button variant="flat" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="border border-default-100 bg-default-50/20">
<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 color="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} className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold text-small truncate">{article.title || 'Artikel'}</div>
<Chip size="sm" variant="flat">{(article.keywords?.length || 0)} Keywords</Chip>
</div>
<p className="text-small text-default-400 line-clamp-2">{article.content || '-'}</p>
<div className="flex gap-2">
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
Bearbeiten
</Button>
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteKbArticle(article.id)}>
Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Keine Artikel" icon={<FileText size={24} />} />}
</div>
</div>
<div>
{kbEditDraft ? (
<Card className="border border-default-100 bg-default-50/20">
<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 minRows={5} value={kbEditDraft.content} onChange={(e) => setKbEditDraft((s) => ({ ...s, content: e.target.value }))} />
</TextField>
<div className="flex gap-2">
<Button color="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
<Button variant="flat" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="border border-default-100 bg-default-50/20">
<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 minRows={5} value={kbDraft.content} onChange={(e) => setKbDraft((s) => ({ ...s, content: e.target.value }))} />
</TextField>
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
</CardContent>
</Card>
)}
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,106 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Switch, Separator, TextField, Label } from '@heroui/react';
import { Sparkles, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
export function Welcome() {
const { settings, setSettings, saveSettingsPayload } = useApp();
return (
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
<div className="grid gap-5 xl:grid-cols-2">
<Card className="surface-card">
<CardHeader>
<div>
<CardTitle>Welcome konfigurieren</CardTitle>
<CardDescription>Standardfelder fuer das Welcome-Embed.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Switch isSelected={settings.welcomeConfig?.enabled !== false} onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}>
<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div>
</Switch>
<TextField>
<Label>Channel ID</Label>
<Input
variant="bordered"
size="lg"
placeholder="Channel ID fuer Willkommensnachrichten"
value={settings.welcomeConfig?.channelId || settings.welcomeChannelId || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), channelId: e.target.value } }))}
/>
</TextField>
<TextField>
<Label>Titel</Label>
<Input
variant="bordered"
size="lg"
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
variant="bordered"
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
variant="bordered"
size="lg"
placeholder={new Date().getFullYear().toString()}
value={settings.welcomeConfig?.embedFooter || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedFooter: e.target.value } }))}
/>
</TextField>
<Separator />
<Button size="lg" variant="light" className="accent-button" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
Speichern
</Button>
</CardContent>
</Card>
<Card className="surface-card">
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>Normale HeroUI-Karten ohne zusaetzliche Huelle.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Card className="surface-card-muted">
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles size={16} className="accent-text" />
<CardTitle>{settings.welcomeConfig?.embedTitle || 'Willkommen!'}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-default-700 dark:text-default-300">{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
{settings.welcomeConfig?.embedFooter && (
<p className="text-small section-subtle">{settings.welcomeConfig.embedFooter}</p>
)}
</CardContent>
</Card>
<p className="text-small section-subtle">
Nutze {'{user}'} fuer den Benutzernamen und {'{server}'} fuer den Servernamen.
</p>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -1,148 +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'
| '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' | 'approved' | 'rejected';
answers: { fieldId?: string; label?: string; value: string }[];
createdAt?: 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,14 +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 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 "$@"

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

File diff suppressed because one or more lines are too long

View File

@@ -141,10 +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',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -161,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'
}; };
@@ -256,45 +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.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -329,18 +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'
}; };
/** /**

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

@@ -141,10 +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',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -161,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'
}; };
@@ -256,45 +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.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -329,18 +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'
}; };
/** /**

View File

@@ -7,9 +7,8 @@
"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": {

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,7 +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);
} }
}; };

View File

@@ -21,7 +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);
} }
}; };

View File

@@ -23,7 +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);
} }
}; };

View File

@@ -25,7 +25,7 @@ const command: SlashCommand = {
await member.ban({ reason: `${reason} | ${minutes} Minuten` }); await member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
context.logging.logAction(user, 'Tempban', reason, interaction.guild); context.logging.logAction(user, 'Tempban', reason);
setTimeout(async () => { setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null); await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);

View File

@@ -23,7 +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 in Timeout gesetzt.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
context.logging.logAction(user, 'Timeout', reason, interaction.guild); context.logging.logAction(user, 'Timeout', reason);
} }
}; };

View File

@@ -19,7 +19,7 @@ const command: SlashCommand = {
} }
await member.timeout(null).catch(() => null); await member.timeout(null).catch(() => null);
await interaction.reply({ content: `${user.tag} ist nun entmuted.` }); await interaction.reply({ content: `${user.tag} ist nun entmuted.` });
context.logging.logAction(user, 'Unmute', undefined, interaction.guild); context.logging.logAction(user, 'Unmute');
} }
}; };

View File

@@ -4,19 +4,15 @@ import { SlashCommand } from '../../utils/types';
const command: SlashCommand = { const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'), data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Papo Hilfe') .setTitle('Papo Hilfe')
.setColor(0xf97316) .setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.addFields( .addFields(
{ name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false }, { name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false },
{ name: '🎫 Tickets', value: '`/ticket` `/ticketpanel` `/ticketpriority` `/ticketstatus` `/transcript`', inline: false }, { name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', inline: false },
{ name: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false }, { name: 'Musik', value: '/play /pause /resume /skip /stop /queue /loop', inline: false },
{ name: '📊 Server-Tools', value: '`/configure` `/serverinfo` `/rank`', inline: false } { name: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
) );
.setFooter({ text: 'Tipp: Nutze /configure für Module & Dashboard-Link' });
await interaction.reply({ embeds: [embed], ephemeral: true }); await interaction.reply({ embeds: [embed], ephemeral: true });
} }
}; };

View File

@@ -15,15 +15,12 @@ import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService'; import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService'; import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService'; import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = { export const context = {
client: null as Client | null, client: null as Client | null,
commandHandler: null as CommandHandler | null, commandHandler: null as CommandHandler | null,
logging, automod: new AutoModService(true, true),
automod: new AutoModService(logging, true, true), logging: new LoggingService(),
music: new MusicService(), music: new MusicService(),
tickets: new TicketService(), tickets: new TicketService(),
leveling: new LevelService(), leveling: new LevelService(),
@@ -36,8 +33,7 @@ export const context = {
events: new EventService(), events: new EventService(),
ticketAutomation: new TicketAutomationService(), ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(), knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(), register: new RegisterService()
stats: new StatsService()
}; };
context.modules.setHooks({ context.modules.setHooks({
@@ -67,10 +63,6 @@ context.modules.setHooks({
}, },
eventsEnabled: { eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined) onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
},
serverStatsEnabled: {
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined),
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined)
} }
}); });

View File

@@ -65,15 +65,6 @@ export interface GuildSettings {
reviewChannelId?: string; reviewChannelId?: string;
notifyRoleIds?: string[]; notifyRoleIds?: string[];
}; };
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string; supportRoleId?: string;
welcomeEnabled?: boolean; welcomeEnabled?: boolean;
} }
@@ -83,23 +74,23 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings { private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg }; const normalized: GuildSettings = { ...cfg };
const defaultOn = [ (
'ticketsEnabled', [
'automodEnabled', 'ticketsEnabled',
'welcomeEnabled', 'automodEnabled',
'levelingEnabled', 'welcomeEnabled',
'musicEnabled', 'levelingEnabled',
'dynamicVoiceEnabled', 'musicEnabled',
'statuspageEnabled', 'dynamicVoiceEnabled',
'birthdayEnabled', 'statuspageEnabled',
'reactionRolesEnabled', 'birthdayEnabled',
'eventsEnabled', 'reactionRolesEnabled',
'registerEnabled' 'eventsEnabled',
] as const; 'registerEnabled'
defaultOn.forEach((key) => { ] as const
).forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true; if (normalized[key] === undefined) normalized[key] = true;
}); });
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present // keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) { if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled }; normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -133,8 +124,6 @@ class SettingsStore {
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined, reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
registerEnabled: (row as any).registerEnabled ?? undefined, registerEnabled: (row as any).registerEnabled ?? undefined,
registerConfig: (row as any).registerConfig ?? undefined, registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings; } satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg)); this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -217,8 +206,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
}, },
create: { create: {
@@ -241,8 +228,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
} }
}); });

View File

@@ -1,2 +1,69 @@
-- Placeholder recreated because this migration was applied in the database already. -- AlterTable
-- No schema changes required locally; keeps migration history aligned. ALTER TABLE "GuildSettings" ADD COLUMN "registerConfig" JSONB,
ADD COLUMN "registerEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "RegisterForm" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"reviewChannelId" TEXT,
"notifyRoleIds" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterFormField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "RegisterFormField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplication" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterApplication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplicationAnswer" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "RegisterApplicationAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegisterForm_guildId_isActive_idx" ON "RegisterForm"("guildId", "isActive");
-- CreateIndex
CREATE INDEX "RegisterApplication_guildId_formId_status_idx" ON "RegisterApplication"("guildId", "formId", "status");
-- AddForeignKey
ALTER TABLE "RegisterFormField" ADD CONSTRAINT "RegisterFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplication" ADD CONSTRAINT "RegisterApplication_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplicationAnswer" ADD CONSTRAINT "RegisterApplicationAnswer_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "RegisterApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,4 +0,0 @@
-- Add server stats module configuration
ALTER TABLE "GuildSettings"
ADD COLUMN "serverStatsEnabled" BOOLEAN,
ADD COLUMN "serverStatsConfig" JSONB;

View File

@@ -28,8 +28,6 @@ model GuildSettings {
eventsEnabled Boolean? eventsEnabled Boolean?
registerEnabled Boolean? registerEnabled Boolean?
registerConfig Json? registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String? supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -182,12 +180,12 @@ model RegisterForm {
} }
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)
} }

View File

@@ -7,7 +7,6 @@ const event: EventHandler = {
execute(channel: GuildChannel) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`); context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
} }
}; };

View File

@@ -7,7 +7,6 @@ const event: EventHandler = {
execute(channel: GuildChannel) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`); context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
} }
}; };

View File

@@ -5,7 +5,7 @@ import { context } from '../config/context';
const event: EventHandler = { const event: EventHandler = {
name: 'guildBanAdd', name: 'guildBanAdd',
execute(ban: GuildBan) { execute(ban: GuildBan) {
context.logging.logAction(ban.user, 'Ban', undefined, ban.guild); context.logging.logAction(ban.user, 'Ban');
} }
}; };

View File

@@ -16,11 +16,8 @@ const event: EventHandler = {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!') .setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`) .setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal); .setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
const footerText = (welcomeCfg.embedFooter || '').trim(); .setFooter({ text: welcomeCfg.embedFooter || '' });
if (footerText) {
embed.setFooter({ text: footerText });
}
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) { if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(','); const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png'; const ext = meta.includes('gif') ? 'gif' : 'png';
@@ -50,7 +47,6 @@ const event: EventHandler = {
} }
} }
context.logging.logMemberJoin(member); context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
} }
}; };

View File

@@ -6,7 +6,6 @@ const event: EventHandler = {
name: 'guildMemberRemove', name: 'guildMemberRemove',
execute(member: GuildMember) { execute(member: GuildMember) {
context.logging.logMemberLeave(member); context.logging.logMemberLeave(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
} }
}; };

View File

@@ -8,7 +8,7 @@ const event: EventHandler = {
async execute(message: Message) { async execute(message: Message) {
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined; const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
if (message.guildId) context.admin.trackEvent('message', message.guildId); if (message.guildId) context.admin.trackEvent('message', message.guildId);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg); if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB // Ticket SLA + KB
await context.tickets.trackFirstResponse(message); await context.tickets.trackFirstResponse(message);

View File

@@ -38,10 +38,6 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) { for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`)); context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`));
} }
context.stats.startScheduler();
for (const [gid] of client.guilds.cache) {
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
}
} catch (err) { } catch (err) {
logger.warn(`Ready handler failed: ${err}`); logger.warn(`Ready handler failed: ${err}`);
} }

View File

@@ -35,7 +35,6 @@ async function bootstrap() {
context.events.setClient(client); context.events.setClient(client);
context.events.startScheduler(); context.events.startScheduler();
context.register.setClient(client); context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache(); await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry)); logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) { for (const gid of settingsStore.all().keys()) {

View File

@@ -1,7 +1,5 @@
import { Collection, Message } from 'discord.js'; import { Collection, Message, PermissionFlagsBits } from 'discord.js';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig { export interface AutomodConfig {
spamThreshold?: number; spamThreshold?: number;
@@ -39,13 +37,11 @@ export class AutoModService {
}; };
private defaultBadwords = ['badword', 'spamword']; private defaultBadwords = ['badword', 'spamword'];
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {} constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) { public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot || message.webhookId) return; if (message.author.bot) return;
if (!message.inGuild()) return; const config = { ...this.defaults, ...(cfg ?? {}) };
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
const member = message.member; const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) { if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
@@ -54,16 +50,23 @@ export class AutoModService {
} }
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) { if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`); if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`; message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.info(`Deleted link from ${message.author.tag}`); logger.info(`Deleted link from ${message.author.tag}`);
await this.logAutomodAction(message, config, 'link_filter', reason); await this.logAutomodAction(message, config, 'link_filter');
return true; return true;
} }
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) { if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`); if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content); message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'badword', message.content);
return true; return true;
} }
@@ -71,9 +74,11 @@ export class AutoModService {
const letters = message.content.replace(/[^a-zA-Z]/g, ''); const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, ''); const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) { if (letters.length >= 10 && upper.length / letters.length > 0.7) {
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`); message.delete().catch(() => undefined);
const ratio = Math.round((upper.length / letters.length) * 100); message.channel
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content); .send({ content: `${message.author}, bitte weniger Capslock nutzen.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'capslock', message.content);
return true; return true;
} }
} }
@@ -93,11 +98,12 @@ export class AutoModService {
if (tracker.count >= threshold) { if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000; const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined); message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`); message.channel
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.warn(`Timed out ${message.author.tag} for spam`); logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id); this.spamTracker.delete(message.author.id);
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`; await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
await this.logAutomodAction(message, config, 'spam', reason);
return true; return true;
} }
} }
@@ -105,52 +111,24 @@ export class AutoModService {
} }
private containsBadword(content: string, custom: string[] = []) { private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])] const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
.map((w) => w?.toString().trim().toLowerCase())
.filter(Boolean);
if (!combined.length) return false; if (!combined.length) return false;
const lower = content.toLowerCase(); const lower = content.toLowerCase();
return combined.some((w) => { return combined.some((w) => lower.includes(w));
// Try to match word boundaries first, fall back to substring to remain permissive
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
return regex.test(lower) || lower.includes(w);
});
} }
private containsLink(content: string, whitelist: string[] = []) { private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean); const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
// Match common link formats, even without protocol const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
if (!match) return false; if (!match) return false;
const url = match[0].toLowerCase(); const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w)); return !normalized.some((w) => url.includes(w));
} }
private async deleteMessageWithReason(message: Message, response: string) { private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
await message.delete().catch(() => undefined);
await message.channel
.send({ content: response })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000))
.catch(() => undefined);
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) {
try { try {
const guild = message.guild; const guild = message.guild;
if (!guild) return; if (!guild) return;
if (this.logging) {
this.logging.logAutomodAction(guild, {
userTag: message.author.tag,
userId: message.author.id,
action,
reason,
content,
channel: guild.channels.cache.get(message.channelId) ?? null,
messageUrl: message.url
});
return;
}
const loggingCfg = config.loggingConfig || {}; const loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {}; const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return; if (flags.automodActions === false) return;
@@ -158,8 +136,8 @@ export class AutoModService {
if (!channelId) return; if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`; const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content: body }); await channel.send({ content });
} catch (err) { } catch (err) {
logger.error('Automod log failed', err); logger.error('Automod log failed', err);
} }

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) { private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id); const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {}; const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId; const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
const flags = loggingCfg.categories || {}; const flags = loggingCfg.categories || {};
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null; const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags }; return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags };
@@ -128,11 +128,11 @@ export class LoggingService {
}); });
} }
logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) { logAction(user: User, action: string, reason?: string) {
const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null); const guild = user instanceof GuildMember ? user.guild : null;
if (!resolvedGuild) return; if (!guild) return;
if (!this.shouldLog(resolvedGuild, 'automodActions')) return; if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(resolvedGuild); const { channel } = this.resolve(guild);
if (!channel) return; if (!channel) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Moderation') .setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da) .setColor(0x7289da)
.setTimestamp(); .setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = resolvedGuild.id; const guildId = (user as GuildMember)?.guild?.id;
if (guildId) { if (guildId) {
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId, guildId,
@@ -154,36 +154,6 @@ export class LoggingService {
} }
} }
logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) {
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Automod')
.setDescription(`${options.userTag} (${options.userId}) -> ${options.action}`)
.addFields(
{ name: 'Grund', value: this.safeField(options.reason) },
{ name: 'Kanal', value: options.channel ? `<#${options.channel.id}>` : 'Unbekannt' }
)
.setColor(0xff006e)
.setTimestamp();
if (options.content) {
embed.addFields({ name: 'Nachricht', value: this.safeField(options.content) });
}
if (options.messageUrl) {
embed.addFields({ name: 'Link', value: options.messageUrl });
}
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message: `Automod: ${options.action} (${options.userTag})`,
timestamp: Date.now(),
category: 'automodActions'
});
adminSink?.trackGuildEvent(guild.id, 'automod');
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) { logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id; const guildId = member.guild.id;
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({

View File

@@ -11,8 +11,7 @@ export type ModuleKey =
| 'birthdayEnabled' | 'birthdayEnabled'
| 'reactionRolesEnabled' | 'reactionRolesEnabled'
| 'eventsEnabled' | 'eventsEnabled'
| 'registerEnabled' | 'registerEnabled';
| 'serverStatsEnabled';
export interface GuildModuleState { export interface GuildModuleState {
key: ModuleKey; key: ModuleKey;
@@ -32,8 +31,7 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' }, birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' }, reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' }, eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' }, registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' }
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
}; };
export class BotModuleService { export class BotModuleService {
@@ -55,7 +53,6 @@ export class BotModuleService {
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true; if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true; if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false; if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false;
if (key === 'serverStatsEnabled') enabled = (cfg as any).serverStatsEnabled === true || (cfg as any).serverStatsConfig?.enabled === true;
return { return {
key: key as ModuleKey, key: key as ModuleKey,
name: meta.name, name: meta.name,

View File

@@ -23,7 +23,7 @@ export class RegisterService {
} }
public async listForms(guildId: string) { public async listForms(guildId: string) {
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } }); return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
} }
public async saveForm(form: { public async saveForm(form: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label, label: f.label,
type: f.type, type: f.type,
required: f.required ?? false, required: f.required ?? false,
order: f.order ?? idx sortOrder: f.order ?? idx
})) }))
}); });
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } }); return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
} }
const created = await prisma.registerForm.create({ const created = await prisma.registerForm.create({
data: { data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label, label: f.label,
type: f.type, type: f.type,
required: f.required ?? false, required: f.required ?? false,
order: f.order ?? idx sortOrder: f.order ?? idx
})) }))
} }
}, },
include: { fields: { orderBy: { order: 'asc' } } } include: { fields: { orderBy: { sortOrder: 'asc' } } }
}); });
return created; return created;
} }
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) { public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) { if (interaction.customId.startsWith('register:form:')) {
const formId = interaction.customId.split(':')[2]; const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: 'asc' } } } }); const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true }); if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`); const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = []; const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2]; const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ const form = await prisma.registerForm.findFirst({
where: { id: formId }, where: { id: formId },
include: { fields: { orderBy: { order: 'asc' } } } include: { fields: { orderBy: { sortOrder: 'asc' } } }
}); });
if (!form) { if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true }); await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
@@ -200,7 +200,7 @@ export class RegisterService {
const channel = await guild.channels.fetch(channelId).catch(() => null); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
const member = await guild.members.fetch(userId).catch(() => null); const member = await guild.members.fetch(userId).catch(() => null);
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } }); const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { sortOrder: 'asc' } });
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } }); const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`) .setTitle(`Registrierung: ${form.name}`)
@@ -254,3 +254,5 @@ export class RegisterService {
}); });
} }
} }

View File

@@ -1,271 +0,0 @@
import { randomUUID } from 'crypto';
import { CategoryChannel, ChannelType, Client, Guild, PermissionFlagsBits } from 'discord.js';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export type StatCounterType =
| 'members_total'
| 'members_humans'
| 'members_bots'
| 'boosts'
| 'text_channels'
| 'voice_channels'
| 'roles';
export interface StatCounter {
id: string;
type: StatCounterType;
label?: string;
format?: string;
channelId?: string;
}
export interface ServerStatsConfig {
enabled: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes: number;
cleanupOrphans?: boolean;
items: StatCounter[];
}
const STAT_META: Record<
StatCounterType,
{
label: string;
defaultFormat: string;
}
> = {
members_total: { label: 'Mitglieder', defaultFormat: '{label}: {value}' },
members_humans: { label: 'Menschen', defaultFormat: '{label}: {value}' },
members_bots: { label: 'Bots', defaultFormat: '{label}: {value}' },
boosts: { label: 'Boosts', defaultFormat: '{label}: {value}' },
text_channels: { label: 'Text Channels', defaultFormat: '{label}: {value}' },
voice_channels: { label: 'Voice Channels', defaultFormat: '{label}: {value}' },
roles: { label: 'Rollen', defaultFormat: '{label}: {value}' }
};
export class StatsService {
private client: Client | null = null;
private interval?: NodeJS.Timeout;
private lastRun = new Map<string, number>();
private syncLocks = new Map<string, Promise<void>>();
public setClient(client: Client) {
this.client = client;
}
public stop() {
if (this.interval) clearInterval(this.interval);
this.interval = undefined;
}
public startScheduler() {
this.stop();
this.interval = setInterval(() => this.tick(), 60 * 1000);
}
public async getConfig(guildId: string): Promise<ServerStatsConfig> {
const cfg = settingsStore.get(guildId);
const statsCfg = (cfg as any)?.serverStatsConfig || {};
const enabled = (cfg as any)?.serverStatsEnabled ?? statsCfg.enabled ?? false;
return this.normalizeConfig({ ...statsCfg, enabled });
}
public async saveConfig(guildId: string, config: Partial<ServerStatsConfig>) {
return this.withGuildLock(guildId, async () => {
const previous = await this.getConfig(guildId);
const normalized = this.normalizeConfig({ ...previous, ...config });
const synced = await this.syncGuild(guildId, normalized, previous);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async refreshGuild(guildId: string) {
return this.withGuildLock(guildId, async () => {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) return cfg;
const synced = await this.syncGuild(guildId, cfg, cfg);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async disableGuild(guildId: string) {
await settingsStore.set(guildId, { serverStatsEnabled: false } as any);
}
private normalizeConfig(config: Partial<ServerStatsConfig>): ServerStatsConfig {
const fallbackItems = Array.isArray(config.items) ? config.items : [];
const items = fallbackItems
.filter((i) => i && (i as any).type && STAT_META[(i as any).type as StatCounterType])
.slice(0, 8)
.map((i) => {
const type = (i as any).type as StatCounterType;
const meta = STAT_META[type];
return {
id: i.id || randomUUID(),
type,
label: i.label || meta.label,
format: i.format || meta.defaultFormat,
channelId: i.channelId
} as StatCounter;
});
const refreshMinutes = Number.isFinite(config.refreshMinutes) ? Number(config.refreshMinutes) : 10;
return {
enabled: !!config.enabled,
categoryId: config.categoryId || undefined,
categoryName: config.categoryName || '📊 Server Stats',
refreshMinutes: Math.max(1, Math.min(180, refreshMinutes)),
cleanupOrphans: config.cleanupOrphans ?? false,
items: items.length ? items : [this.defaultItem()]
};
}
private defaultItem(): StatCounter {
return {
id: randomUUID(),
type: 'members_total',
label: STAT_META['members_total'].label,
format: STAT_META['members_total'].defaultFormat
};
}
private formatName(item: StatCounter, value: number) {
const meta = STAT_META[item.type];
const label = item.label || meta.label;
const base = (item.format || meta.defaultFormat || '{label}: {value}')
.replace('{label}', label)
.replace('{value}', value.toLocaleString('de-DE'));
return base.slice(0, 96);
}
private async syncGuild(guildId: string, cfg: ServerStatsConfig, previous?: ServerStatsConfig): Promise<ServerStatsConfig> {
if (!this.client) return cfg;
const guild = await this.client.guilds.fetch(guildId).catch(() => null);
if (!guild) return cfg;
const category = await this.ensureCategory(guild, cfg);
const managedIds = new Set<string>();
for (const item of cfg.items) {
const value = this.computeValue(guild, item.type);
const desiredName = this.formatName(item, value);
let channel =
(item.channelId && (await guild.channels.fetch(item.channelId).catch(() => null))) ||
null;
if (!channel) {
channel = await guild.channels
.create({
name: desiredName,
type: ChannelType.GuildVoice,
parent: category?.id,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }],
userLimit: 0,
bitrate: 8000
})
.catch((err) => {
logger.warn(`Failed to create stats channel in ${guild.id}: ${err?.message || err}`);
return null;
});
if (channel) item.channelId = channel.id;
} else if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
const needsParent = category && channel.parentId !== category.id;
const overwritesMissing = !channel.permissionOverwrites.cache.some(
(ow) => ow.id === guild.roles.everyone.id && ow.deny.has(PermissionFlagsBits.Connect)
);
const editData: any = { name: desiredName };
if (needsParent) editData.parent = category.id;
if (overwritesMissing) editData.permissionOverwrites = [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }];
if ((channel as any).userLimit !== 0) editData.userLimit = 0;
await channel.edit(editData).catch(() => undefined);
}
if (channel?.id) managedIds.add(channel.id);
}
if (cfg.cleanupOrphans && previous?.items) {
for (const old of previous.items) {
if (old.channelId && !managedIds.has(old.channelId)) {
const ch = await guild.channels.fetch(old.channelId).catch(() => null);
if (ch && ch.parentId === category?.id) {
await ch.delete('Papo Server Stats entfernt').catch(() => undefined);
}
}
}
}
return { ...cfg, categoryId: category?.id };
}
private async ensureCategory(guild: Guild, cfg: ServerStatsConfig) {
if (cfg.categoryId) {
const existing = await guild.channels.fetch(cfg.categoryId).catch(() => null);
if (existing && existing.type === ChannelType.GuildCategory) return existing as CategoryChannel;
}
const name = cfg.categoryName || '📊 Server Stats';
const found = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase() === name.toLowerCase()
) as CategoryChannel | undefined;
if (found) return found;
const created = await guild.channels
.create({
name,
type: ChannelType.GuildCategory,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }]
})
.catch(() => null);
return created as CategoryChannel | null;
}
private computeValue(guild: Guild, type: StatCounterType): number {
switch (type) {
case 'members_total':
return guild.memberCount ?? 0;
case 'members_humans': {
const humans = guild.members.cache.filter((m) => !m.user.bot).size;
return humans || Math.max(0, (guild.memberCount ?? 0) - guild.members.cache.filter((m) => m.user.bot).size);
}
case 'members_bots':
return guild.members.cache.filter((m) => m.user.bot).size;
case 'boosts':
return guild.premiumSubscriptionCount ?? 0;
case 'text_channels':
return guild.channels.cache.filter((c) => c.isTextBased() && c.type !== ChannelType.GuildVoice && c.type !== ChannelType.GuildStageVoice).size;
case 'voice_channels':
return guild.channels.cache.filter((c) => c.isVoiceBased()).size;
case 'roles':
return guild.roles.cache.size;
default:
return guild.memberCount ?? 0;
}
}
private async tick() {
const now = Date.now();
for (const guildId of settingsStore.all().keys()) {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) continue;
const last = this.lastRun.get(guildId) || 0;
const intervalMs = Math.max(1, cfg.refreshMinutes) * 60 * 1000;
if (now - last < intervalMs) continue;
await this.refreshGuild(guildId).catch(() => undefined);
}
}
private async withGuildLock<T>(guildId: string, task: () => Promise<T>): Promise<T> {
const waitFor = this.syncLocks.get(guildId) || Promise.resolve();
const run = (async () => {
await waitFor.catch(() => undefined);
return task();
})();
this.syncLocks.set(guildId, run.then(() => undefined, () => undefined));
try {
return await run;
} finally {
const current = this.syncLocks.get(guildId);
if (current === run) this.syncLocks.delete(guildId);
}
}
}

View File

@@ -85,8 +85,7 @@ router.get('/guild/info', requireAuth, async (req, res) => {
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false, dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false, statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false, birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false, reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
} }
} }
}); });
@@ -106,35 +105,6 @@ router.get('/guild/logs', requireAuth, (req, res) => {
res.json({ logs }); res.json({ logs });
}); });
router.get('/guild/resources', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const guild = context.client?.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ error: 'guild not found' });
const channels = guild.channels.cache
.filter((c) => c.isTextBased() || c.isVoiceBased())
.map((c) => ({
id: c.id,
name: c.name,
type: c.isVoiceBased() ? 'voice' : 'text',
parentId: c.parentId
}))
.sort((a, b) => a.name.localeCompare(b.name));
const roles = guild.roles.cache
.filter((r) => r.name !== '@everyone')
.map((r) => ({
id: r.id,
name: r.name,
color: r.hexColor
}))
.sort((a, b) => b.rawPosition - a.rawPosition);
const categories = guild.channels.cache
.filter((c) => c.type === 4)
.map((c) => ({ id: c.id, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name));
res.json({ channels, roles, categories });
});
router.get('/overview', requireAuth, async (req, res) => { router.get('/overview', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
try { try {
@@ -784,27 +754,6 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/server-stats', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = await context.stats.getConfig(guildId);
res.json({ config: cfg });
});
router.post('/server-stats', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = await context.stats.saveConfig(guildId, req.body.config || {});
res.json({ config: cfg });
});
router.post('/server-stats/refresh', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.stats.refreshGuild(guildId);
res.json({ ok: true });
});
router.post('/settings', requireAuth, async (req, res) => { router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {}; const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const { const {
@@ -830,9 +779,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled, reactionRolesEnabled,
reactionRolesConfig, reactionRolesConfig,
registerEnabled, registerEnabled,
registerConfig, registerConfig
serverStatsEnabled,
serverStatsConfig
} = req.body; } = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) => const normalizeArray = (val: any) =>
@@ -966,9 +913,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled, reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles, reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled, registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister, registerConfig: parsedRegister
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
}); });
// Live update logging target // Live update logging target
context.logging = new LoggingService(updated.logChannelId); context.logging = new LoggingService(updated.logChannelId);

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ import express from 'express';
import session from 'express-session'; import session from 'express-session';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import path from 'path'; import path from 'path';
import fs from 'fs';
import authRouter from './routes/auth'; import authRouter from './routes/auth';
import dashboardRouter from './routes/dashboard'; import dashboardRouter from './routes/dashboard';
import apiRouter from './routes/api'; import apiRouter from './routes/api';
@@ -12,6 +11,7 @@ export function createWebServer() {
const app = express(); const app = express();
const basePath = env.webBasePath || '/ucp'; const basePath = env.webBasePath || '/ucp';
const dashboardPath = `${basePath}/dashboard`; const dashboardPath = `${basePath}/dashboard`;
const apiPath = `${basePath}/api`;
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
app.use(cookieParser()); app.use(cookieParser());
app.use( app.use(
@@ -24,10 +24,12 @@ export function createWebServer() {
const mount = (suffix: string) => (basePath ? `${basePath}${suffix}` : suffix); const mount = (suffix: string) => (basePath ? `${basePath}${suffix}` : suffix);
app.use(mount('/auth'), authRouter); app.use(mount('/auth'), authRouter);
app.use(dashboardPath, dashboardRouter);
app.use(mount('/api'), apiRouter); app.use(mount('/api'), apiRouter);
// fallback mounts if proxy strips base path // fallback mounts if proxy strips base path
if (basePath) { if (basePath) {
app.use('/api', apiRouter); app.use('/api', apiRouter);
app.use('/dashboard', dashboardRouter);
} }
// Redirect bare auth calls to the prefixed path when a base path is set // Redirect bare auth calls to the prefixed path when a base path is set
@@ -35,66 +37,36 @@ export function createWebServer() {
app.use('/auth', (_req, res) => res.redirect(`${basePath}${_req.originalUrl}`)); app.use('/auth', (_req, res) => res.redirect(`${basePath}${_req.originalUrl}`));
} }
// Serve React SPA static assets // Landing pages
const frontendDist = path.join(process.cwd(), 'frontend', 'dist'); app.get('/', (_req, res) => res.redirect(dashboardPath));
app.get(basePath || '/', (_req, res) => {
// If SPA exists, it takes precedence for GET dashboard routes res.send(`
if (fs.existsSync(path.join(frontendDist, 'index.html'))) { <!doctype html>
const spaHtml = fs.readFileSync(path.join(frontendDist, 'index.html'), 'utf-8'); <html lang="de">
const configScript = `window.__PAPO__ = ${JSON.stringify({ <head>
baseRoot: basePath, <meta charset="UTF-8" />
baseApi: mount('/api'), <meta name="viewport" content="width=device-width, initial-scale=1.0" />
baseAuth: mount('/auth'), <title>Papo Dashboard</title>
baseDashboard: dashboardPath <style>
})}`; :root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
app.use(basePath || '/', express.static(frontendDist)); .shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
app.get(`${dashboardPath}(/*)?`, (_req, res) => { p { margin:0 0 18px; color:var(--muted); }
res.type('html').send(spaHtml.replace('__PAPO_CONFIG__', configScript)); a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
}); a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
</style>
app.get(mount('/'), (_req, res) => { </head>
res.redirect(dashboardPath); <body>
}); <div class="shell">
} else { <h1>Papo Dashboard</h1>
// Legacy landing page when SPA is not built <p>Verwalte Tickets, Module und Automod.</p>
app.get(mount('/'), (_req, res) => { <a href="${dashboardPath}">Zum Dashboard</a>
res.send(` </div>
<!doctype html> </body>
<html lang="de"> </html>
<head> `);
<meta charset="UTF-8" /> });
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Papo Dashboard</title>
<style>
:root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
.shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
p { margin:0 0 18px; color:var(--muted); }
a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
</style>
</head>
<body>
<div class="shell">
<h1>Papo Dashboard</h1>
<p>Verwalte Tickets, Module und Automod.</p>
<a href="${dashboardPath}/">Zum Dashboard</a>
</div>
</body>
</html>
`);
});
}
// Old dashboard router only handles POST/non-GET routes when SPA is active,
// or all routes when SPA is inactive
app.use(dashboardPath, dashboardRouter);
if (basePath) {
app.use('/dashboard', dashboardRouter);
}
app.use(mount('/static'), express.static(path.join(process.cwd(), 'static'))); app.use(mount('/static'), express.static(path.join(process.cwd(), 'static')));
return app; return app;

View File

@@ -1,92 +0,0 @@
:root {
--bg: #080c15;
--card: rgba(16,19,28,0.65);
--text: #f7fafc;
--accent: #f97316;
--accent-strong: #ff9b3d;
--muted: #a8b2c5;
--border: rgba(255,255,255,0.08);
--surface: rgba(12,15,22,0.75);
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at 12% 18%, rgba(249,115,22,0.12), transparent 32%), radial-gradient(circle at 78% -6%, rgba(255,153,73,0.12), transparent 32%), linear-gradient(135deg, #070a11 0%, #0b0f18 50%, #080c15 100%);
color: var(--text);
min-height: 100vh;
}
.layout {
display: flex;
width: 100%;
min-height: 100vh;
background: radial-gradient(circle at 50% 20%, rgba(255,153,73,0.08), transparent 30%);
}
.sidebar {
width: 240px;
background: linear-gradient(180deg, rgba(12,14,22,0.85), rgba(10,12,18,0.78));
border-right: 1px solid rgba(255,255,255,0.06);
padding: 24px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 18px;
backdrop-filter: blur(14px);
box-shadow: 8px 0 32px rgba(0,0,0,0.45);
}
.brand {
font-size: 20px;
font-weight: 800;
letter-spacing: 0.6px;
color: var(--text);
}
.nav { display: flex; flex-direction: column; gap: 8px; }
.nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
border-radius: 14px;
color: var(--muted);
text-decoration: none;
font-weight: 700;
transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease, border 140ms ease;
border: 1px solid transparent;
background: rgba(255,255,255,0.02);
}
.nav a .icon { opacity: 0.85; width: 18px; display: inline-flex; justify-content: center; }
.nav a.active {
background: rgba(249,115,22,0.18);
color: var(--accent-strong);
border-color: rgba(249,115,22,0.45);
box-shadow: 0 10px 30px rgba(249,115,22,0.28);
}
.nav a:hover {
background: rgba(255,255,255,0.06);
color: var(--text);
border-color: rgba(255,255,255,0.08);
box-shadow: 0 8px 22px rgba(0,0,0,0.28);
}
.nav a.hidden { display: none; }
.content {
flex: 1;
padding: 28px 34px 56px;
box-sizing: border-box;
background: linear-gradient(145deg, rgba(255,153,73,0.05) 0%, rgba(255,255,255,0) 32%);
}
h1 { margin: 0; font-size: 26px; letter-spacing: 0.6px; font-weight: 800; }
.muted { color: var(--muted); font-size: 13px; }
main { padding-top: 18px; display: flex; flex-direction: column; gap: 18px; }
.section { display: none; }
.section.active { display: block; }
.section.hidden { display: none !important; }
.logout {
margin-top: auto;
border: none;
color: white;
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
background: linear-gradient(135deg, #ef4444, #b91c1c);
width: 100%;
box-shadow: 0 12px 28px rgba(239,68,68,0.32);
}