Compare commits

..

11 Commits

Author SHA1 Message Date
Pascal Prießnitz
dca8dda045 [deploy] Fix register newline literal
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:54:48 +01:00
Pascal Prießnitz
8639eafbf7 [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:46:40 +01:00
Pascal Prießnitz
a9cc01dfaa [deploy] Fix dashboard syntax error by stripping bad icons
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 21:29:38 +01:00
Pascal Prießnitz
90bb12d054 [deploy] Fix dashboard nav encoding causing syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:19:31 +01:00
Pascal Prießnitz
c7136553c7 [deploy] Cleanup dashboard nav encoding and fix syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-03 19:47:27 +01:00
Pascal Prießnitz
5acd81f87d [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 19:14:07 +01:00
Pascal Prießnitz
336708191b [deploy
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 19:01:36 +01:00
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
91 changed files with 3700 additions and 8334 deletions

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

@@ -4,7 +4,7 @@ services:
app:
build:
context: .
dockerfile: dockerfile
dockerfile: Dockerfile
image: papo-app:latest
working_dir: /usr/src/app
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 PRISMA_IGNORE_ENV_LOAD=true
# Install backend dependencies
# Install dependencies (inkl. dev)
COPY package*.json ./
RUN npm ci --include=dev
# Install frontend dependencies
COPY frontend/package*.json ./frontend/
RUN npm --prefix frontend ci
# Copy source
COPY . .
# Build frontend
RUN npm run build:web
# Ensure prisma CLI available globally (avoids path issues)
RUN npm install -g prisma@5.4.2
# Copy source
COPY . .
# Generate Prisma client (explicit schema path)
RUN prisma generate --schema=src/database/schema.prisma
# Build backend (tsc emits JS even with type errors; exit code suppressed for pre-existing errors)
RUN npm run build:web && npx tsc || true
# Optional: show versions in build log
RUN node -v && npm -v && npx prisma -v
CMD ["npm", "start"]
CMD ["npm", "run", "dev"]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,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 "$@"

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

File diff suppressed because one or more lines are too long

View File

@@ -143,8 +143,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -274,7 +272,7 @@ exports.Prisma.RegisterFormFieldScalarFieldEnum = {
label: 'label',
type: 'type',
required: 'required',
order: 'order'
sortOrder: 'sortOrder'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {

View File

@@ -2086,7 +2086,6 @@ export namespace Prisma {
reactionRolesEnabled: boolean | null
eventsEnabled: boolean | null
registerEnabled: boolean | null
serverStatsEnabled: boolean | null
supportRoleId: string | null
updatedAt: Date | null
createdAt: Date | null
@@ -2106,7 +2105,6 @@ export namespace Prisma {
reactionRolesEnabled: boolean | null
eventsEnabled: boolean | null
registerEnabled: boolean | null
serverStatsEnabled: boolean | null
supportRoleId: string | null
updatedAt: Date | null
createdAt: Date | null
@@ -2133,8 +2131,6 @@ export namespace Prisma {
eventsEnabled: number
registerEnabled: number
registerConfig: number
serverStatsEnabled: number
serverStatsConfig: number
supportRoleId: number
updatedAt: number
createdAt: number
@@ -2156,7 +2152,6 @@ export namespace Prisma {
reactionRolesEnabled?: true
eventsEnabled?: true
registerEnabled?: true
serverStatsEnabled?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2176,7 +2171,6 @@ export namespace Prisma {
reactionRolesEnabled?: true
eventsEnabled?: true
registerEnabled?: true
serverStatsEnabled?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2203,8 +2197,6 @@ export namespace Prisma {
eventsEnabled?: true
registerEnabled?: true
registerConfig?: true
serverStatsEnabled?: true
serverStatsConfig?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2304,8 +2296,6 @@ export namespace Prisma {
eventsEnabled: boolean | null
registerEnabled: boolean | null
registerConfig: JsonValue | null
serverStatsEnabled: boolean | null
serverStatsConfig: JsonValue | null
supportRoleId: string | null
updatedAt: Date
createdAt: Date
@@ -2349,8 +2339,6 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2377,8 +2365,6 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2405,8 +2391,6 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2437,8 +2421,6 @@ export namespace Prisma {
eventsEnabled: boolean | null
registerEnabled: boolean | null
registerConfig: Prisma.JsonValue | null
serverStatsEnabled: boolean | null
serverStatsConfig: Prisma.JsonValue | null
supportRoleId: string | null
updatedAt: Date
createdAt: Date
@@ -2855,8 +2837,6 @@ export namespace Prisma {
readonly eventsEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly registerEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly registerConfig: FieldRef<"GuildSettings", 'Json'>
readonly serverStatsEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly serverStatsConfig: FieldRef<"GuildSettings", 'Json'>
readonly supportRoleId: FieldRef<"GuildSettings", 'String'>
readonly updatedAt: FieldRef<"GuildSettings", 'DateTime'>
readonly createdAt: FieldRef<"GuildSettings", 'DateTime'>
@@ -12732,11 +12712,11 @@ export namespace Prisma {
}
export type RegisterFormFieldAvgAggregateOutputType = {
order: number | null
sortOrder: number | null
}
export type RegisterFormFieldSumAggregateOutputType = {
order: number | null
sortOrder: number | null
}
export type RegisterFormFieldMinAggregateOutputType = {
@@ -12745,7 +12725,7 @@ export namespace Prisma {
label: string | null
type: string | null
required: boolean | null
order: number | null
sortOrder: number | null
}
export type RegisterFormFieldMaxAggregateOutputType = {
@@ -12754,7 +12734,7 @@ export namespace Prisma {
label: string | null
type: string | null
required: boolean | null
order: number | null
sortOrder: number | null
}
export type RegisterFormFieldCountAggregateOutputType = {
@@ -12763,17 +12743,17 @@ export namespace Prisma {
label: number
type: number
required: number
order: number
sortOrder: number
_all: number
}
export type RegisterFormFieldAvgAggregateInputType = {
order?: true
sortOrder?: true
}
export type RegisterFormFieldSumAggregateInputType = {
order?: true
sortOrder?: true
}
export type RegisterFormFieldMinAggregateInputType = {
@@ -12782,7 +12762,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
order?: true
sortOrder?: true
}
export type RegisterFormFieldMaxAggregateInputType = {
@@ -12791,7 +12771,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
order?: true
sortOrder?: true
}
export type RegisterFormFieldCountAggregateInputType = {
@@ -12800,7 +12780,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
order?: true
sortOrder?: true
_all?: true
}
@@ -12896,7 +12876,7 @@ export namespace Prisma {
label: string
type: string
required: boolean
order: number
sortOrder: number
_count: RegisterFormFieldCountAggregateOutputType | null
_avg: RegisterFormFieldAvgAggregateOutputType | null
_sum: RegisterFormFieldSumAggregateOutputType | null
@@ -12924,7 +12904,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
order?: boolean
sortOrder?: boolean
form?: boolean | RegisterFormDefaultArgs<ExtArgs>
}, ExtArgs["result"]["registerFormField"]>
@@ -12934,7 +12914,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
order?: boolean
sortOrder?: boolean
form?: boolean | RegisterFormDefaultArgs<ExtArgs>
}, ExtArgs["result"]["registerFormField"]>
@@ -12944,7 +12924,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
order?: boolean
sortOrder?: boolean
}
export type RegisterFormFieldInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
@@ -12965,7 +12945,7 @@ export namespace Prisma {
label: string
type: string
required: boolean
order: number
sortOrder: number
}, ExtArgs["result"]["registerFormField"]>
composites: {}
}
@@ -13365,7 +13345,7 @@ export namespace Prisma {
readonly label: FieldRef<"RegisterFormField", 'String'>
readonly type: FieldRef<"RegisterFormField", 'String'>
readonly required: FieldRef<"RegisterFormField", 'Boolean'>
readonly order: FieldRef<"RegisterFormField", 'Int'>
readonly sortOrder: FieldRef<"RegisterFormField", 'Int'>
}
@@ -15649,8 +15629,6 @@ export namespace Prisma {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -15813,7 +15791,7 @@ export namespace Prisma {
label: 'label',
type: 'type',
required: 'required',
order: 'order'
sortOrder: 'sortOrder'
};
export type RegisterFormFieldScalarFieldEnum = (typeof RegisterFormFieldScalarFieldEnum)[keyof typeof RegisterFormFieldScalarFieldEnum]
@@ -15993,8 +15971,6 @@ export namespace Prisma {
eventsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableFilter<"GuildSettings">
supportRoleId?: StringNullableFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeFilter<"GuildSettings"> | Date | string
@@ -16021,8 +15997,6 @@ export namespace Prisma {
eventsEnabled?: SortOrderInput | SortOrder
registerEnabled?: SortOrderInput | SortOrder
registerConfig?: SortOrderInput | SortOrder
serverStatsEnabled?: SortOrderInput | SortOrder
serverStatsConfig?: SortOrderInput | SortOrder
supportRoleId?: SortOrderInput | SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -16052,8 +16026,6 @@ export namespace Prisma {
eventsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableFilter<"GuildSettings">
supportRoleId?: StringNullableFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeFilter<"GuildSettings"> | Date | string
@@ -16080,8 +16052,6 @@ export namespace Prisma {
eventsEnabled?: SortOrderInput | SortOrder
registerEnabled?: SortOrderInput | SortOrder
registerConfig?: SortOrderInput | SortOrder
serverStatsEnabled?: SortOrderInput | SortOrder
serverStatsConfig?: SortOrderInput | SortOrder
supportRoleId?: SortOrderInput | SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -16114,8 +16084,6 @@ export namespace Prisma {
eventsEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableWithAggregatesFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableWithAggregatesFilter<"GuildSettings">
supportRoleId?: StringNullableWithAggregatesFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeWithAggregatesFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeWithAggregatesFilter<"GuildSettings"> | Date | string
@@ -16863,7 +16831,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
order?: IntFilter<"RegisterFormField"> | number
sortOrder?: IntFilter<"RegisterFormField"> | number
form?: XOR<RegisterFormRelationFilter, RegisterFormWhereInput>
}
@@ -16873,7 +16841,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
order?: SortOrder
sortOrder?: SortOrder
form?: RegisterFormOrderByWithRelationInput
}
@@ -16886,7 +16854,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
order?: IntFilter<"RegisterFormField"> | number
sortOrder?: IntFilter<"RegisterFormField"> | number
form?: XOR<RegisterFormRelationFilter, RegisterFormWhereInput>
}, "id">
@@ -16896,7 +16864,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
order?: SortOrder
sortOrder?: SortOrder
_count?: RegisterFormFieldCountOrderByAggregateInput
_avg?: RegisterFormFieldAvgOrderByAggregateInput
_max?: RegisterFormFieldMaxOrderByAggregateInput
@@ -16913,7 +16881,7 @@ export namespace Prisma {
label?: StringWithAggregatesFilter<"RegisterFormField"> | string
type?: StringWithAggregatesFilter<"RegisterFormField"> | string
required?: BoolWithAggregatesFilter<"RegisterFormField"> | boolean
order?: IntWithAggregatesFilter<"RegisterFormField"> | number
sortOrder?: IntWithAggregatesFilter<"RegisterFormField"> | number
}
export type RegisterApplicationWhereInput = {
@@ -17060,8 +17028,6 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17088,8 +17054,6 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17116,8 +17080,6 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17144,8 +17106,6 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17172,8 +17132,6 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17200,8 +17158,6 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17228,8 +17184,6 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -18077,7 +18031,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
form: RegisterFormCreateNestedOneWithoutFieldsInput
}
@@ -18087,7 +18041,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
}
export type RegisterFormFieldUpdateInput = {
@@ -18095,7 +18049,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
form?: RegisterFormUpdateOneRequiredWithoutFieldsNestedInput
}
@@ -18105,7 +18059,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldCreateManyInput = {
@@ -18114,7 +18068,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
}
export type RegisterFormFieldUpdateManyMutationInput = {
@@ -18122,7 +18076,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateManyInput = {
@@ -18131,7 +18085,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterApplicationCreateInput = {
@@ -18356,8 +18310,6 @@ export namespace Prisma {
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
registerConfig?: SortOrder
serverStatsEnabled?: SortOrder
serverStatsConfig?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -18377,7 +18329,6 @@ export namespace Prisma {
reactionRolesEnabled?: SortOrder
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
serverStatsEnabled?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -18397,7 +18348,6 @@ export namespace Prisma {
reactionRolesEnabled?: SortOrder
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
serverStatsEnabled?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -19070,11 +19020,11 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
order?: SortOrder
sortOrder?: SortOrder
}
export type RegisterFormFieldAvgOrderByAggregateInput = {
order?: SortOrder
sortOrder?: SortOrder
}
export type RegisterFormFieldMaxOrderByAggregateInput = {
@@ -19083,7 +19033,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
order?: SortOrder
sortOrder?: SortOrder
}
export type RegisterFormFieldMinOrderByAggregateInput = {
@@ -19092,11 +19042,11 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
order?: SortOrder
sortOrder?: SortOrder
}
export type RegisterFormFieldSumOrderByAggregateInput = {
order?: SortOrder
sortOrder?: SortOrder
}
export type RegisterApplicationAnswerListRelationFilter = {
@@ -19851,7 +19801,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
}
export type RegisterFormFieldUncheckedCreateWithoutFormInput = {
@@ -19859,7 +19809,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
}
export type RegisterFormFieldCreateOrConnectWithoutFormInput = {
@@ -19929,7 +19879,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
order?: IntFilter<"RegisterFormField"> | number
sortOrder?: IntFilter<"RegisterFormField"> | number
}
export type RegisterApplicationUpsertWithWhereUniqueWithoutFormInput = {
@@ -20243,7 +20193,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
order?: number
sortOrder?: number
}
export type RegisterApplicationCreateManyFormInput = {
@@ -20261,7 +20211,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateWithoutFormInput = {
@@ -20269,7 +20219,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateManyWithoutFormInput = {
@@ -20277,7 +20227,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
order?: IntFieldUpdateOperationsInput | number
sortOrder?: IntFieldUpdateOperationsInput | number
}
export type RegisterApplicationUpdateWithoutFormInput = {

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

File diff suppressed because one or more lines are too long

View File

@@ -143,8 +143,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -274,7 +272,7 @@ exports.Prisma.RegisterFormFieldScalarFieldEnum = {
label: 'label',
type: 'type',
required: 'required',
order: 'order'
sortOrder: 'sortOrder'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {

View File

@@ -7,9 +7,8 @@
"schema": "src/database/schema.prisma"
},
"scripts": {
"build:web": "npm --prefix frontend run build",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "npm run build:web && tsc",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {

View File

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

View File

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

347
readme.md
View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ const command: SlashCommand = {
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
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 () => {
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 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 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 = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder()
.setTitle('Papo Hilfe')
.setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.setTitle('Papo Hilfe')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
.addFields(
{ name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', 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: '📊 Server-Tools', value: '`/configure` `/serverinfo` `/rank`', inline: false }
)
.setFooter({ text: 'Tipp: Nutze /configure für Module & Dashboard-Link' });
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', 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: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};

View File

@@ -15,15 +15,12 @@ import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = {
client: null as Client | null,
commandHandler: null as CommandHandler | null,
logging,
automod: new AutoModService(logging, true, true),
automod: new AutoModService(true, true),
logging: new LoggingService(),
music: new MusicService(),
tickets: new TicketService(),
leveling: new LevelService(),
@@ -36,8 +33,7 @@ export const context = {
events: new EventService(),
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(),
stats: new StatsService()
register: new RegisterService()
};
context.modules.setHooks({
@@ -67,10 +63,6 @@ context.modules.setHooks({
},
eventsEnabled: {
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;
notifyRoleIds?: string[];
};
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string;
welcomeEnabled?: boolean;
}
@@ -83,7 +74,8 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg };
const defaultOn = [
(
[
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
@@ -95,11 +87,10 @@ class SettingsStore {
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
] as const;
defaultOn.forEach((key) => {
] as const
).forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
});
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -133,8 +124,6 @@ class SettingsStore {
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
registerEnabled: (row as any).registerEnabled ?? undefined,
registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -217,8 +206,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
},
create: {
@@ -241,8 +228,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
}
});

View File

@@ -1,2 +1,69 @@
-- Placeholder recreated because this migration was applied in the database already.
-- No schema changes required locally; keeps migration history aligned.
-- AlterTable
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?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -187,7 +185,7 @@ model RegisterFormField {
label String
type String
required Boolean @default(false)
order Int @default(0)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

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

View File

@@ -6,7 +6,6 @@ const event: EventHandler = {
name: 'guildMemberRemove',
execute(member: GuildMember) {
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) {
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
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);
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);

View File

@@ -38,10 +38,6 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) {
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) {
logger.warn(`Ready handler failed: ${err}`);
}

View File

@@ -35,7 +35,6 @@ async function bootstrap() {
context.events.setClient(client);
context.events.startScheduler();
context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry));
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 { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig {
spamThreshold?: number;
@@ -39,13 +37,11 @@ export class AutoModService {
};
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) {
if (message.author.bot || message.webhookId) return;
if (!message.inGuild()) return;
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot) return;
const config = { ...this.defaults, ...(cfg ?? {}) };
const member = message.member;
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)) {
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
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}`);
await this.logAutomodAction(message, config, 'link_filter', reason);
await this.logAutomodAction(message, config, 'link_filter');
return true;
}
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
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;
}
@@ -71,9 +74,11 @@ export class AutoModService {
const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
const ratio = Math.round((upper.length / letters.length) * 100);
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
message.delete().catch(() => undefined);
message.channel
.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;
}
}
@@ -93,11 +98,12 @@ export class AutoModService {
if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
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`);
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', reason);
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
return true;
}
}
@@ -105,52 +111,24 @@ export class AutoModService {
}
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])]
.map((w) => w?.toString().trim().toLowerCase())
.filter(Boolean);
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
if (!combined.length) return false;
const lower = content.toLowerCase();
return combined.some((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);
});
return combined.some((w) => lower.includes(w));
}
private containsLink(content: string, whitelist: string[] = []) {
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]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
if (!match) return false;
const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w));
}
private async deleteMessageWithReason(message: Message, response: 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) {
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
try {
const guild = message.guild;
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 flags = loggingCfg.categories || {};
if (flags.automodActions === false) return;
@@ -158,8 +136,8 @@ export class AutoModService {
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content: body });
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content });
} catch (err) {
logger.error('Automod log failed', err);
}

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id);
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 channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
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) {
const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
if (!resolvedGuild) return;
if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(resolvedGuild);
logAction(user: User, action: string, reason?: string) {
const guild = user instanceof GuildMember ? user.guild : null;
if (!guild) return;
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da)
.setTimestamp();
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) {
adminSink?.pushGuildLog({
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[]) {
const guildId = member.guild.id;
adminSink?.pushGuildLog({

View File

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

View File

@@ -1,6 +1,6 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, StreamType, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl';
import { Readable } from 'stream';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
@@ -8,7 +8,8 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem {
title: string;
url: string;
streamUrl: string;
displayUrl?: string;
requester: string;
originalQuery?: string;
}
@@ -24,6 +25,7 @@ interface QueueState {
export class MusicService {
private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId);
@@ -82,7 +84,13 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return;
}
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
const queueItem: QueueItem = {
title: track.title ?? 'Unbekannt',
streamUrl: track.url,
displayUrl: track.url,
requester: interaction.user.tag,
originalQuery: trimmedQuery
};
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
@@ -160,7 +168,7 @@ export class MusicService {
next = queue.current;
}
if (!next) break;
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
const streamUrlCheck = typeof next.streamUrl === 'string' ? next.streamUrl.trim() : '';
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break;
}
@@ -172,24 +180,16 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
const streamUrl = (next.url || '').trim();
const streamUrl = (next.streamUrl || '').trim();
queue.current = next;
try {
const kind = await play.validate(streamUrl);
if (kind !== 'so_track') {
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
return;
}
const finalUrl = streamUrl;
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
const stream = await play.stream(finalUrl);
if (!stream?.stream) throw new Error('stream_invalid');
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid');
const res = await fetch(streamUrl);
if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed');
const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body;
const resource: AudioResource = createAudioResource(body, {
inputType: StreamType.Arbitrary
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
@@ -234,39 +234,79 @@ export class MusicService {
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
let validation: string | null = null;
try {
validation = await play.validate(trimmed);
} catch (err) {
logger.warn('Music validate error', err);
const token = await this.getSpotifyToken();
const trackId = this.extractSpotifyTrackId(trimmed);
if (trackId) {
const track = await this.fetchSpotifyTrack(trackId, token);
if (track) return track;
}
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
const search = await this.searchSpotifyTrack(trimmed, token);
return search;
}
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
logger.warn('SoundCloud search skipped', err?.message || err);
return [];
private async getSpotifyToken(): Promise<string> {
if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) {
return this.spotifyToken.value;
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('missing_spotify_credentials');
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
if (scSearch && scSearch.length) {
const sc = scSearch[0];
const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
if (!res.ok) throw new Error('spotify_auth_failed');
const data = (await res.json()) as { access_token: string; expires_in: number };
const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000);
this.spotifyToken = { value: data.access_token, expiresAt: Date.now() + expiresInMs };
return data.access_token;
}
private extractSpotifyTrackId(query: string) {
const urlMatch = query.match(/spotify\.com\/track\/([A-Za-z0-9]+)/i);
if (urlMatch?.[1]) return urlMatch[1];
const uriMatch = query.match(/spotify:track:([A-Za-z0-9]+)/i);
if (uriMatch?.[1]) return uriMatch[1];
return null;
}
private buildVideoUrl(details: any): string | null {
if (!details) return null;
const url = details.url || details.permalink;
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> {
const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify track fetch failed', { status: res.status });
return null;
}
const data: any = await res.json();
if (!data?.preview_url) {
logger.warn('Spotify track has no preview_url', { id });
return null;
}
const artists = Array.isArray(data.artists) ? data.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [data.name, artists].filter(Boolean).join(' - ');
return { title: title || data.name || 'Track', url: data.preview_url };
}
private async searchSpotifyTrack(query: string, token: string): Promise<{ title: string; url: string } | null> {
const market = process.env.SPOTIFY_MARKET || 'DE';
const res = await fetch(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=1&market=${market}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify search failed', { status: res.status });
return null;
}
const data: any = await res.json();
const track = data?.tracks?.items?.[0];
if (!track || !track.preview_url) return null;
const artists = Array.isArray(track.artists) ? track.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [track.name, artists].filter(Boolean).join(' - ');
return { title: title || track.name || 'Track', url: track.preview_url };
}
}

View File

@@ -23,7 +23,7 @@ export class RegisterService {
}
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: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label,
type: f.type,
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({
data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
sortOrder: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
return created;
}
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) {
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 });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({
where: { id: formId },
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
if (!form) {
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);
if (!channel || !channel.isTextBased()) return;
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 embed = new EmbedBuilder()
.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,
statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
}
}
});
@@ -106,35 +105,6 @@ router.get('/guild/logs', requireAuth, (req, res) => {
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) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
try {
@@ -784,27 +754,6 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
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) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const {
@@ -830,9 +779,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled,
reactionRolesConfig,
registerEnabled,
registerConfig,
serverStatsEnabled,
serverStatsConfig
registerConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -966,9 +913,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
registerConfig: parsedRegister
});
// Live update logging target
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 cookieParser from 'cookie-parser';
import path from 'path';
import fs from 'fs';
import authRouter from './routes/auth';
import dashboardRouter from './routes/dashboard';
import apiRouter from './routes/api';
@@ -12,6 +11,7 @@ export function createWebServer() {
const app = express();
const basePath = env.webBasePath || '/ucp';
const dashboardPath = `${basePath}/dashboard`;
const apiPath = `${basePath}/api`;
app.use(express.json({ limit: '5mb' }));
app.use(cookieParser());
app.use(
@@ -24,10 +24,12 @@ export function createWebServer() {
const mount = (suffix: string) => (basePath ? `${basePath}${suffix}` : suffix);
app.use(mount('/auth'), authRouter);
app.use(dashboardPath, dashboardRouter);
app.use(mount('/api'), apiRouter);
// fallback mounts if proxy strips base path
if (basePath) {
app.use('/api', apiRouter);
app.use('/dashboard', dashboardRouter);
}
// Redirect bare auth calls to the prefixed path when a base path is set
@@ -35,31 +37,9 @@ export function createWebServer() {
app.use('/auth', (_req, res) => res.redirect(`${basePath}${_req.originalUrl}`));
}
// Serve React SPA static assets
const frontendDist = path.join(process.cwd(), 'frontend', 'dist');
// If SPA exists, it takes precedence for GET dashboard routes
if (fs.existsSync(path.join(frontendDist, 'index.html'))) {
const spaHtml = fs.readFileSync(path.join(frontendDist, 'index.html'), 'utf-8');
const configScript = `window.__PAPO__ = ${JSON.stringify({
baseRoot: basePath,
baseApi: mount('/api'),
baseAuth: mount('/auth'),
baseDashboard: dashboardPath
})}`;
app.use(basePath || '/', express.static(frontendDist));
app.get(`${dashboardPath}(/*)?`, (_req, res) => {
res.type('html').send(spaHtml.replace('__PAPO_CONFIG__', configScript));
});
app.get(mount('/'), (_req, res) => {
res.redirect(dashboardPath);
});
} else {
// Legacy landing page when SPA is not built
app.get(mount('/'), (_req, res) => {
// Landing pages
app.get('/', (_req, res) => res.redirect(dashboardPath));
app.get(basePath || '/', (_req, res) => {
res.send(`
<!doctype html>
<html lang="de">
@@ -81,20 +61,12 @@ export function createWebServer() {
<div class="shell">
<h1>Papo Dashboard</h1>
<p>Verwalte Tickets, Module und Automod.</p>
<a href="${dashboardPath}/">Zum Dashboard</a>
<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')));
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);
}

0
tmp_locate.log Normal file
View File