Compare commits
4 Commits
main
...
e21d9e11b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21d9e11b6 | ||
|
|
5681e14f8d | ||
|
|
67643cb54d | ||
|
|
86282fbe07 |
@@ -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
2
.gitignore
vendored
@@ -1,4 +1,2 @@
|
||||
.env
|
||||
node_modules
|
||||
debug.log
|
||||
dist/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dockerfile
|
||||
dockerfile: Dockerfile
|
||||
image: papo-app:latest
|
||||
working_dir: /usr/src/app
|
||||
env_file:
|
||||
|
||||
20
dockerfile
20
dockerfile
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
3312
frontend/package-lock.json
generated
3312
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} /> },
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 :emoji: | 123456789 | Rolle 1 :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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} /> }
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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()]
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
RUNNER_VERSION="${RUNNER_VERSION:-2.0.0}"
|
||||
RUNNER_USER="${RUNNER_USER:-act_runner}"
|
||||
RUNNER_HOME="${RUNNER_HOME:-/var/lib/act_runner}"
|
||||
RUNNER_CONFIG_DIR="${RUNNER_CONFIG_DIR:-/etc/act_runner}"
|
||||
RUNNER_CONFIG_FILE="${RUNNER_CONFIG_FILE:-$RUNNER_CONFIG_DIR/config.yaml}"
|
||||
RUNNER_BINARY_PATH="${RUNNER_BINARY_PATH:-/usr/local/bin/gitea-runner}"
|
||||
RUNNER_COMPAT_SYMLINK_PATH="${RUNNER_COMPAT_SYMLINK_PATH:-/usr/local/bin/act_runner}"
|
||||
RUNNER_NAME="${RUNNER_NAME:-$(hostname)}"
|
||||
RUNNER_LABELS="${RUNNER_LABELS:-linux_amd64:host,ubuntu-latest:docker://node:20-bookworm}"
|
||||
GITEA_INSTANCE_URL="${GITEA_INSTANCE_URL:-}"
|
||||
GITEA_RUNNER_TOKEN="${GITEA_RUNNER_TOKEN:-}"
|
||||
REGISTER_RUNNER="${REGISTER_RUNNER:-true}"
|
||||
|
||||
detect_runner_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Nicht unterstützte Architektur: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[RUNNER] %s\n' "$1"
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [ "${EUID}" -ne 0 ]; then
|
||||
echo "Dieses Script muss als root laufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
log "Installiere Systempakete"
|
||||
apt update
|
||||
apt install -y docker.io curl wget unzip git ca-certificates python3
|
||||
systemctl enable --now docker
|
||||
}
|
||||
|
||||
create_runner_user() {
|
||||
log "Erstelle Runner-User und Verzeichnisse"
|
||||
|
||||
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
||||
useradd --system --create-home --shell /bin/bash "$RUNNER_USER"
|
||||
fi
|
||||
|
||||
usermod -aG docker "$RUNNER_USER"
|
||||
mkdir -p "$RUNNER_HOME" "$RUNNER_CONFIG_DIR"
|
||||
chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" "$RUNNER_CONFIG_DIR"
|
||||
}
|
||||
|
||||
install_runner_binary() {
|
||||
local tmp_dir runner_arch asset_name download_url
|
||||
|
||||
log "Installiere act_runner ${RUNNER_VERSION}"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
runner_arch="$(detect_runner_arch)"
|
||||
asset_name="gitea-runner-${RUNNER_VERSION}-linux-${runner_arch}"
|
||||
download_url="https://dl.gitea.com/gitea-runner/${RUNNER_VERSION}/${asset_name}"
|
||||
|
||||
curl -fsSL "$download_url" -o "${tmp_dir}/gitea-runner"
|
||||
install -m 0755 "${tmp_dir}/gitea-runner" "$RUNNER_BINARY_PATH"
|
||||
ln -sf "$RUNNER_BINARY_PATH" "$RUNNER_COMPAT_SYMLINK_PATH"
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
"$RUNNER_BINARY_PATH" --version
|
||||
}
|
||||
|
||||
generate_config() {
|
||||
log "Erzeuge Runner-Konfiguration"
|
||||
sudo -u "$RUNNER_USER" -H bash -lc "\"$RUNNER_BINARY_PATH\" generate-config > \"$RUNNER_CONFIG_FILE\""
|
||||
|
||||
python3 - "$RUNNER_CONFIG_FILE" "$RUNNER_LABELS" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
labels = [label.strip() for label in sys.argv[2].split(",") if label.strip()]
|
||||
content = config_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
out = []
|
||||
in_runner = False
|
||||
labels_written = False
|
||||
skip_existing_labels = False
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == "runner:":
|
||||
in_runner = True
|
||||
out.append(line)
|
||||
continue
|
||||
|
||||
if in_runner and line and not line.startswith(" "):
|
||||
if not labels_written:
|
||||
out.append(" labels:")
|
||||
for label in labels:
|
||||
out.append(f" - \"{label}\"")
|
||||
labels_written = True
|
||||
in_runner = False
|
||||
|
||||
if in_runner and stripped.startswith("labels:"):
|
||||
skip_existing_labels = True
|
||||
continue
|
||||
|
||||
if skip_existing_labels:
|
||||
if line.startswith(" - ") or stripped == "":
|
||||
continue
|
||||
skip_existing_labels = False
|
||||
|
||||
out.append(line)
|
||||
|
||||
if in_runner and not labels_written:
|
||||
out.append(" labels:")
|
||||
for label in labels:
|
||||
out.append(f" - \"{label}\"")
|
||||
|
||||
config_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_CONFIG_FILE"
|
||||
}
|
||||
|
||||
register_runner() {
|
||||
if [ "$REGISTER_RUNNER" != "true" ]; then
|
||||
log "Runner-Registrierung übersprungen"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "$GITEA_INSTANCE_URL" ] || [ -z "$GITEA_RUNNER_TOKEN" ]; then
|
||||
echo "Für die Registrierung werden GITEA_INSTANCE_URL und GITEA_RUNNER_TOKEN benötigt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Registriere Runner bei ${GITEA_INSTANCE_URL}"
|
||||
sudo -u "$RUNNER_USER" -H bash -lc "cd \"$RUNNER_HOME\" && rm -f .runner && \"$RUNNER_BINARY_PATH\" register --no-interactive --instance \"$GITEA_INSTANCE_URL\" --token \"$GITEA_RUNNER_TOKEN\" --name \"$RUNNER_NAME\" --labels \"$RUNNER_LABELS\""
|
||||
}
|
||||
|
||||
install_service() {
|
||||
log "Installiere systemd-Service"
|
||||
cat >/etc/systemd/system/act_runner.service <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Actions runner
|
||||
Documentation=https://docs.gitea.com/usage/actions/act-runner
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
User=${RUNNER_USER}
|
||||
WorkingDirectory=${RUNNER_HOME}
|
||||
ExecStart=${RUNNER_BINARY_PATH} daemon --config ${RUNNER_CONFIG_FILE}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now act_runner
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
log "Fertig"
|
||||
echo
|
||||
echo "Service-Status:"
|
||||
systemctl --no-pager --full status act_runner || true
|
||||
echo
|
||||
echo "Wichtige Pfade:"
|
||||
echo " Binary: ${RUNNER_BINARY_PATH}"
|
||||
echo " Symlink: ${RUNNER_COMPAT_SYMLINK_PATH}"
|
||||
echo " Config: ${RUNNER_CONFIG_FILE}"
|
||||
echo " Home: ${RUNNER_HOME}"
|
||||
echo
|
||||
echo "Beispiel mit direkter Registrierung:"
|
||||
echo " sudo GITEA_INSTANCE_URL=https://gitea.example.tld GITEA_RUNNER_TOKEN=TOKEN ./install-gitea-runner.sh"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_root
|
||||
install_packages
|
||||
create_runner_user
|
||||
install_runner_binary
|
||||
generate_config
|
||||
register_runner
|
||||
install_service
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
81
node_modules/.prisma/client/edge.js
generated
vendored
81
node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
75
node_modules/.prisma/client/index-browser.js
generated
vendored
75
node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -141,10 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
|
||||
reactionRolesEnabled: 'reactionRolesEnabled',
|
||||
reactionRolesConfig: 'reactionRolesConfig',
|
||||
eventsEnabled: 'eventsEnabled',
|
||||
registerEnabled: 'registerEnabled',
|
||||
registerConfig: 'registerConfig',
|
||||
serverStatsEnabled: 'serverStatsEnabled',
|
||||
serverStatsConfig: 'serverStatsConfig',
|
||||
supportRoleId: 'supportRoleId',
|
||||
updatedAt: 'updatedAt',
|
||||
createdAt: 'createdAt'
|
||||
@@ -161,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
|
||||
status: 'status',
|
||||
claimedBy: 'claimedBy',
|
||||
transcript: 'transcript',
|
||||
firstClaimAt: 'firstClaimAt',
|
||||
firstResponseAt: 'firstResponseAt',
|
||||
kbSuggestionSentAt: 'kbSuggestionSentAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
condition: 'condition',
|
||||
action: 'action',
|
||||
active: 'active',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
title: 'title',
|
||||
keywords: 'keywords',
|
||||
content: 'content',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -256,45 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
|
||||
canceledAt: 'canceledAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
reviewChannelId: 'reviewChannelId',
|
||||
notifyRoleIds: 'notifyRoleIds',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
|
||||
id: 'id',
|
||||
formId: 'formId',
|
||||
label: 'label',
|
||||
type: 'type',
|
||||
required: 'required',
|
||||
order: 'order'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
formId: 'formId',
|
||||
status: 'status',
|
||||
reviewedBy: 'reviewedBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
fieldId: 'fieldId',
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -329,18 +262,12 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
GuildSettings: 'GuildSettings',
|
||||
Ticket: 'Ticket',
|
||||
TicketAutomationRule: 'TicketAutomationRule',
|
||||
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
|
||||
Level: 'Level',
|
||||
TicketSupportSession: 'TicketSupportSession',
|
||||
Birthday: 'Birthday',
|
||||
ReactionRoleSet: 'ReactionRoleSet',
|
||||
Event: 'Event',
|
||||
EventSignup: 'EventSignup',
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
EventSignup: 'EventSignup'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
8569
node_modules/.prisma/client/index.d.ts
generated
vendored
8569
node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
81
node_modules/.prisma/client/index.js
generated
vendored
81
node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
75
node_modules/.prisma/client/wasm.js
generated
vendored
75
node_modules/.prisma/client/wasm.js
generated
vendored
@@ -141,10 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
|
||||
reactionRolesEnabled: 'reactionRolesEnabled',
|
||||
reactionRolesConfig: 'reactionRolesConfig',
|
||||
eventsEnabled: 'eventsEnabled',
|
||||
registerEnabled: 'registerEnabled',
|
||||
registerConfig: 'registerConfig',
|
||||
serverStatsEnabled: 'serverStatsEnabled',
|
||||
serverStatsConfig: 'serverStatsConfig',
|
||||
supportRoleId: 'supportRoleId',
|
||||
updatedAt: 'updatedAt',
|
||||
createdAt: 'createdAt'
|
||||
@@ -161,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
|
||||
status: 'status',
|
||||
claimedBy: 'claimedBy',
|
||||
transcript: 'transcript',
|
||||
firstClaimAt: 'firstClaimAt',
|
||||
firstResponseAt: 'firstResponseAt',
|
||||
kbSuggestionSentAt: 'kbSuggestionSentAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
condition: 'condition',
|
||||
action: 'action',
|
||||
active: 'active',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
title: 'title',
|
||||
keywords: 'keywords',
|
||||
content: 'content',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -256,45 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
|
||||
canceledAt: 'canceledAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
reviewChannelId: 'reviewChannelId',
|
||||
notifyRoleIds: 'notifyRoleIds',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
|
||||
id: 'id',
|
||||
formId: 'formId',
|
||||
label: 'label',
|
||||
type: 'type',
|
||||
required: 'required',
|
||||
order: 'order'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
formId: 'formId',
|
||||
status: 'status',
|
||||
reviewedBy: 'reviewedBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
fieldId: 'fieldId',
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -329,18 +262,12 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
GuildSettings: 'GuildSettings',
|
||||
Ticket: 'Ticket',
|
||||
TicketAutomationRule: 'TicketAutomationRule',
|
||||
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
|
||||
Level: 'Level',
|
||||
TicketSupportSession: 'TicketSupportSession',
|
||||
Birthday: 'Birthday',
|
||||
ReactionRoleSet: 'ReactionRoleSet',
|
||||
Event: 'Event',
|
||||
EventSignup: 'EventSignup',
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
EventSignup: 'EventSignup'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
@@ -9,31 +9,26 @@ datasource db {
|
||||
}
|
||||
|
||||
model GuildSettings {
|
||||
guildId String @id
|
||||
welcomeChannelId String?
|
||||
logChannelId String?
|
||||
automodEnabled Boolean?
|
||||
automodConfig Json?
|
||||
levelingEnabled Boolean?
|
||||
ticketsEnabled Boolean?
|
||||
musicEnabled Boolean?
|
||||
statuspageEnabled Boolean?
|
||||
statuspageConfig Json?
|
||||
guildId String @id
|
||||
welcomeChannelId String?
|
||||
logChannelId String?
|
||||
automodEnabled Boolean?
|
||||
automodConfig Json?
|
||||
levelingEnabled Boolean?
|
||||
ticketsEnabled Boolean?
|
||||
musicEnabled Boolean?
|
||||
statuspageEnabled Boolean?
|
||||
statuspageConfig Json?
|
||||
dynamicVoiceEnabled Boolean?
|
||||
dynamicVoiceConfig Json?
|
||||
supportLoginConfig Json?
|
||||
birthdayEnabled Boolean?
|
||||
birthdayConfig Json?
|
||||
supportLoginConfig Json?
|
||||
birthdayEnabled Boolean?
|
||||
birthdayConfig Json?
|
||||
reactionRolesEnabled Boolean?
|
||||
reactionRolesConfig Json?
|
||||
eventsEnabled Boolean?
|
||||
registerEnabled Boolean?
|
||||
registerConfig Json?
|
||||
serverStatsEnabled Boolean?
|
||||
serverStatsConfig Json?
|
||||
supportRoleId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -177,17 +172,18 @@ model RegisterForm {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
fields RegisterFormField[]
|
||||
applications RegisterApplication[]
|
||||
|
||||
@@index([guildId, isActive])
|
||||
}
|
||||
|
||||
model RegisterFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
order Int @default(0)
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
347
readme.md
347
readme.md
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,23 +74,23 @@ class SettingsStore {
|
||||
|
||||
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
|
||||
const normalized: GuildSettings = { ...cfg };
|
||||
const defaultOn = [
|
||||
'ticketsEnabled',
|
||||
'automodEnabled',
|
||||
'welcomeEnabled',
|
||||
'levelingEnabled',
|
||||
'musicEnabled',
|
||||
'dynamicVoiceEnabled',
|
||||
'statuspageEnabled',
|
||||
'birthdayEnabled',
|
||||
'reactionRolesEnabled',
|
||||
'eventsEnabled',
|
||||
'registerEnabled'
|
||||
] as const;
|
||||
defaultOn.forEach((key) => {
|
||||
(
|
||||
[
|
||||
'ticketsEnabled',
|
||||
'automodEnabled',
|
||||
'welcomeEnabled',
|
||||
'levelingEnabled',
|
||||
'musicEnabled',
|
||||
'dynamicVoiceEnabled',
|
||||
'statuspageEnabled',
|
||||
'birthdayEnabled',
|
||||
'reactionRolesEnabled',
|
||||
'eventsEnabled',
|
||||
'registerEnabled'
|
||||
] 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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Add server stats module configuration
|
||||
ALTER TABLE "GuildSettings"
|
||||
ADD COLUMN "serverStatsEnabled" BOOLEAN,
|
||||
ADD COLUMN "serverStatsConfig" JSONB;
|
||||
@@ -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())
|
||||
@@ -182,12 +180,12 @@ model RegisterForm {
|
||||
}
|
||||
|
||||
model RegisterFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
order Int @default(0)
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const event: EventHandler = {
|
||||
name: 'guildMemberRemove',
|
||||
execute(member: GuildMember) {
|
||||
context.logging.logMemberLeave(member);
|
||||
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,66 +37,36 @@ 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) => {
|
||||
res.send(`
|
||||
<!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>
|
||||
<style>
|
||||
:root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
|
||||
.shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
|
||||
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
|
||||
p { margin:0 0 18px; color:var(--muted); }
|
||||
a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
|
||||
a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<h1>Papo Dashboard</h1>
|
||||
<p>Verwalte Tickets, Module und Automod.</p>
|
||||
<a href="${dashboardPath}/">Zum Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Old dashboard router – only handles POST/non-GET routes when SPA is active,
|
||||
// or all routes when SPA is inactive
|
||||
app.use(dashboardPath, dashboardRouter);
|
||||
if (basePath) {
|
||||
app.use('/dashboard', dashboardRouter);
|
||||
}
|
||||
// Landing pages
|
||||
app.get('/', (_req, res) => res.redirect(dashboardPath));
|
||||
app.get(basePath || '/', (_req, res) => {
|
||||
res.send(`
|
||||
<!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>
|
||||
<style>
|
||||
:root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
|
||||
.shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
|
||||
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
|
||||
p { margin:0 0 18px; color:var(--muted); }
|
||||
a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
|
||||
a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<h1>Papo Dashboard</h1>
|
||||
<p>Verwalte Tickets, Module und Automod.</p>
|
||||
<a href="${dashboardPath}">Zum Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
app.use(mount('/static'), express.static(path.join(process.cwd(), 'static')));
|
||||
return app;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user