Compare commits
2 Commits
main
...
22caa79b54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22caa79b54 | ||
|
|
311f5a87f1 |
@@ -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,32 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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, Bell, 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 text-default-400 min-w-0">
|
||||
<button className="hover:text-foreground transition-colors" onClick={() => setSection('overview')}>
|
||||
Dashboard
|
||||
</button>
|
||||
{section !== 'overview' && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium truncate">{navLabels[section] || section}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusMessage && (
|
||||
<Chip color="warning" size="sm" variant="flat" className="max-w-[200px]">
|
||||
<span className="truncate">{statusMessage}</span>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Tooltip content={dark ? 'Helles Design' : 'Dunkles Design'} placement="bottom">
|
||||
<Button isIconOnly radius="lg" size="sm" variant="light" onPress={toggle}>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button className="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,191 +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={`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' : ''}`}>
|
||||
<Avatar name="Papo" radius="lg" />
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-bold">Papo</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-default-400">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-widest text-default-400">
|
||||
{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="h-9 justify-start gap-3 px-2"
|
||||
color={isActive ? 'primary' : 'default'}
|
||||
radius="lg"
|
||||
variant={isActive ? 'flat' : '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-widest text-default-400">
|
||||
Admin
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={collapsed ? 'Admin' : ''} placement="right" offset={8}>
|
||||
<Button
|
||||
className="h-9 justify-start gap-3 px-2"
|
||||
color={section === 'admin' ? 'warning' : 'default'}
|
||||
radius="lg"
|
||||
variant={section === 'admin' ? 'flat' : '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="w-full"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
||||
</Button>
|
||||
|
||||
<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] text-default-400">Angemeldet</div>
|
||||
</div>
|
||||
<Tooltip content="Abmelden" placement="top">
|
||||
<Button isIconOnly color="danger" radius="lg" size="sm" variant="light" 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>
|
||||
<CardHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{subtitle && <CardDescription>{subtitle}</CardDescription>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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}>
|
||||
{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 text-default-500">{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>
|
||||
<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 text-default-400">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} color="primary" size="sm" variant="flat">
|
||||
{key.replace('Enabled', '').replace(/([A-Z])/g, ' $1').trim()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Chip color="primary" radius="sm" size="sm" variant="flat" startContent={<Gauge size={12} />}>
|
||||
Ping: {guildInfo?.ping || '-'}ms
|
||||
</Chip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<StatCard icon={<Users size={18} />} label="Mitglieder" value={guildInfo?.memberCount ?? 0} />
|
||||
<StatCard icon={<Activity size={18} />} label="Online" value={guildInfo?.onlineCount ?? '-'} />
|
||||
<StatCard icon={<Hash size={18} />} label="Channels" value={`${guildInfo?.textCount || 0}`} />
|
||||
<StatCard icon={<Shield size={18} />} label="Rollen" value={guildInfo?.roleCount ?? '-'} />
|
||||
<StatCard icon={<ArrowUpRight size={18} />} label="Boost Level" value={guildInfo?.boostLevel ?? '-'} />
|
||||
<StatCard icon={<Ticket size={18} />} label="Offene Tickets" value={overview?.tickets?.open ?? 0} color="warning" />
|
||||
<StatCard icon={<Command size={18} />} label="Commands (24h)" value={activity?.commands24h ?? 0} />
|
||||
<StatCard icon={<MessageSquare size={18} />} label="Nachrichten (24h)" value={activity?.messages24h ?? 0} />
|
||||
<StatCard icon={<Bot size={18} />} label="Automod (24h)" value={activity?.automod24h ?? 0} color="danger" />
|
||||
<StatCard icon={<Clock size={18} />} label="Uptime" value={guildInfo?.uptime || '-'} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Activity Bereich</h2>
|
||||
<p className="mt-0.5 text-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>
|
||||
<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" 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}`}>
|
||||
<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-primary-500/10 text-primary-400'
|
||||
}`}>
|
||||
{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>
|
||||
<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}
|
||||
className="justify-start"
|
||||
color={action.color}
|
||||
startContent={action.icon}
|
||||
variant="flat"
|
||||
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)}>
|
||||
<CardContent className="flex flex-col items-start gap-2">
|
||||
<Chip color="primary" size="sm" variant="flat" startContent={item.icon}>
|
||||
{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="border border-success-100/30 bg-success-50/5">
|
||||
<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} 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="border border-default-100 bg-default-50/20 opacity-70">
|
||||
<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} 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,139 +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>
|
||||
<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
|
||||
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
|
||||
value={supportLogin?.config?.title || 'Support Login'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: e.target.value } } : s)}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Beschreibung</Label>
|
||||
<TextArea
|
||||
value={supportLogin?.config?.description || ''}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: e.target.value } } : s)}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Login Button Label</Label>
|
||||
<Input
|
||||
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: e.target.value } } : s)}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Logout Button Label</Label>
|
||||
<Input
|
||||
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
|
||||
onChange={(e) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: e.target.value } } : s)}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={saveSupportLogin}>
|
||||
Speichern & Panel senden
|
||||
</Button>
|
||||
<Button variant="flat" startContent={<Send size={16} />}>
|
||||
Panel manuell senden
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Live Vorschau</CardTitle>
|
||||
<CardDescription>Panel in einer normalen HeroUI-Karte.</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogIn size={16} />
|
||||
<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" color="primary" variant="flat">{supportLogin?.config?.loginLabel || 'Login'}</Button>
|
||||
<Button size="sm" variant="flat">{supportLogin?.config?.logoutLabel || 'Logout'}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>Aktive Supporter</CardTitle>
|
||||
<CardDescription>Aktueller Status aus der Guild.</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
|
||||
<Card key={i}>
|
||||
<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,99 +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>
|
||||
<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
|
||||
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
|
||||
placeholder="Willkommen {user}!"
|
||||
value={settings.welcomeConfig?.embedTitle || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedTitle: e.target.value } }))}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Beschreibung</Label>
|
||||
<TextArea
|
||||
placeholder="Beschreibung des Embeds"
|
||||
value={settings.welcomeConfig?.embedDescription || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedDescription: e.target.value } }))}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Footer</Label>
|
||||
<Input
|
||||
placeholder={new Date().getFullYear().toString()}
|
||||
value={settings.welcomeConfig?.embedFooter || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedFooter: e.target.value } }))}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button color="primary" startContent={<Save size={16} />} onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} />
|
||||
<CardTitle>{settings.welcomeConfig?.embedTitle || 'Willkommen!'}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}</p>
|
||||
{settings.welcomeConfig?.embedFooter && (
|
||||
<p className="text-small text-default-500">{settings.welcomeConfig.embedFooter}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-small text-default-500">
|
||||
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 "$@"
|
||||
@@ -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": {
|
||||
|
||||
336
public/styles/dashboard.css
Normal file
336
public/styles/dashboard.css
Normal file
@@ -0,0 +1,336 @@
|
||||
:root {
|
||||
--bg: #080c15;
|
||||
--card: rgba(16, 19, 28, 0.65);
|
||||
--surface: rgba(12, 15, 22, 0.75);
|
||||
--text: #f7fafc;
|
||||
--accent: #f97316;
|
||||
--accent-strong: #ff9b3d;
|
||||
--muted: #a8b2c5;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
background: linear-gradient(145deg, rgba(255, 153, 73, 0.05) 0%, rgba(255, 255, 255, 0) 32%);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select,
|
||||
select {
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(249, 115, 22, 0.4);
|
||||
background: linear-gradient(130deg, #ff9b3d, #f97316);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 14px 30px rgba(249, 115, 22, 0.35);
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 18px 40px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 12px 28px rgba(239, 68, 68, 0.32);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
main .section {
|
||||
margin-top: 22px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
main .section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.card.clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.card.clickable:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(249, 115, 22, 0.35);
|
||||
box-shadow: 0 22px 40px rgba(0, 0, 0, 0.4);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
margin: 4px 0 0 0;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: rgba(249, 115, 22, 0.16);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
box-shadow: 0 8px 18px rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
border: 1px solid rgba(249, 115, 22, 0.45);
|
||||
color: #ffe6d0;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
z-index: 1100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #ffe4e6;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-backdrop.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
width: min(640px, 94vw);
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
padding: 12px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgba(229, 231, 235, 0.65);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
106
public/styles/dashboard.sections.css
Normal file
106
public/styles/dashboard.sections.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.tickets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ticket-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 14px;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
color: var(--accent-strong);
|
||||
border-color: rgba(249, 115, 22, 0.45);
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #f87171;
|
||||
border-color: rgba(239, 68, 68, 0.42);
|
||||
}
|
||||
|
||||
.module-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.module-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border 140ms ease, box-shadow 140ms ease;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background: rgba(249, 115, 22, 0.3);
|
||||
border-color: rgba(249, 115, 22, 0.6);
|
||||
box-shadow: 0 8px 20px rgba(249, 115, 22, 0.24);
|
||||
}
|
||||
|
||||
.switch.on::after {
|
||||
transform: translateX(22px);
|
||||
background: #ffd9b3;
|
||||
box-shadow: 0 6px 14px rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
3
public/ts-build/app.js
Normal file
3
public/ts-build/app.js
Normal file
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("./core/app.js");
|
||||
26
public/ts-build/components/admin/index.js
Normal file
26
public/ts-build/components/admin/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initAdminSection = initAdminSection;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function initAdminSection(guildId) {
|
||||
const section = document.getElementById('section-admin');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = '<p class="muted">Lade Admin-Daten...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.settings(guildId);
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Admin</h2>
|
||||
<div class="card">
|
||||
<p class="muted">Rohdaten (nur Admin):</p>
|
||||
<pre style="white-space:pre-wrap;max-height:320px;overflow:auto;">${JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Admin-Daten konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Admin-Daten', true);
|
||||
}
|
||||
}
|
||||
93
public/ts-build/components/dashboard.js
Normal file
93
public/ts-build/components/dashboard.js
Normal file
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initDashboardView = initDashboardView;
|
||||
const api_js_1 = require("../services/api.js");
|
||||
const store_js_1 = require("../state/store.js");
|
||||
const toast_js_1 = require("../ui/toast.js");
|
||||
const overview_js_1 = require("./overview.js");
|
||||
const index_js_1 = require("./tickets/index.js");
|
||||
const index_js_2 = require("./modules/index.js");
|
||||
const index_js_3 = require("./events/index.js");
|
||||
const index_js_4 = require("./admin/index.js");
|
||||
const settings_js_1 = require("./settings.js");
|
||||
let overviewInterval = null;
|
||||
let ticketsInterval = null;
|
||||
async function populateGuildSelect() {
|
||||
const select = document.getElementById('guildSelect');
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
if (!select || !cfg)
|
||||
return;
|
||||
select.innerHTML = `<option>Loading...</option>`;
|
||||
try {
|
||||
const data = await api_js_1.api.guilds();
|
||||
select.innerHTML = '';
|
||||
data.guilds.forEach((g) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.id;
|
||||
opt.textContent = g.name;
|
||||
if (g.id === cfg.initialGuildId)
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
const current = select.value || cfg.initialGuildId || data.guilds[0]?.id;
|
||||
(0, store_js_1.setState)({ guildId: current || undefined });
|
||||
select.value = current || '';
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
(0, toast_js_1.showToast)('Guilds konnten nicht geladen werden', true);
|
||||
}
|
||||
}
|
||||
function registerGuildChange() {
|
||||
const select = document.getElementById('guildSelect');
|
||||
if (!select)
|
||||
return;
|
||||
select.addEventListener('change', () => {
|
||||
const guildId = select.value;
|
||||
(0, store_js_1.setState)({ guildId });
|
||||
refreshSections();
|
||||
});
|
||||
}
|
||||
async function refreshSections() {
|
||||
const { guildId } = (0, store_js_1.getState)();
|
||||
if (!guildId)
|
||||
return;
|
||||
await (0, overview_js_1.renderOverview)(guildId);
|
||||
await (0, index_js_1.initTicketsSection)(guildId);
|
||||
await (0, index_js_2.initModulesSection)(guildId);
|
||||
await (0, settings_js_1.renderSettingsSection)(guildId);
|
||||
await (0, index_js_3.initEventsSection)(guildId);
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
if (cfg?.isAdmin) {
|
||||
await (0, index_js_4.initAdminSection)(guildId);
|
||||
}
|
||||
}
|
||||
function setupPolling() {
|
||||
const { guildId } = (0, store_js_1.getState)();
|
||||
if (overviewInterval)
|
||||
window.clearInterval(overviewInterval);
|
||||
if (ticketsInterval)
|
||||
window.clearInterval(ticketsInterval);
|
||||
overviewInterval = window.setInterval(() => {
|
||||
const current = (0, store_js_1.getState)().guildId;
|
||||
if (current)
|
||||
(0, overview_js_1.renderOverview)(current);
|
||||
}, 10000);
|
||||
ticketsInterval = window.setInterval(() => {
|
||||
const current = (0, store_js_1.getState)().guildId;
|
||||
if (current)
|
||||
(0, index_js_1.initTicketsSection)(current);
|
||||
}, 12000);
|
||||
}
|
||||
function initDashboardView() {
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn && cfg) {
|
||||
logoutBtn.addEventListener('click', () => (window.location.href = `${cfg.baseAuth}/logout`));
|
||||
}
|
||||
populateGuildSelect().then(() => {
|
||||
registerGuildChange();
|
||||
refreshSections();
|
||||
setupPolling();
|
||||
});
|
||||
}
|
||||
43
public/ts-build/components/events/index.js
Normal file
43
public/ts-build/components/events/index.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initEventsSection = initEventsSection;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function initEventsSection(guildId) {
|
||||
const section = document.getElementById('section-events');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = '<p class="muted">Lade Events...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.events(guildId);
|
||||
const events = data?.events || data || [];
|
||||
section.innerHTML = '<h2 class="section-title">Events</h2>';
|
||||
if (!events.length) {
|
||||
section.innerHTML += '<div class="empty-state">Keine Events geplant.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
events.forEach((ev) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;">${ev.title || 'Event'}</div>
|
||||
<div class="muted">${ev.date || ''}</div>
|
||||
</div>
|
||||
<span class="pill">${ev.status || 'open'}</span>
|
||||
</div>
|
||||
<div class="muted">${ev.description || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
section.appendChild(list);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Events konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Events', true);
|
||||
}
|
||||
}
|
||||
59
public/ts-build/components/guildSelect.js
Normal file
59
public/ts-build/components/guildSelect.js
Normal file
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initSelectionView = initSelectionView;
|
||||
const api_js_1 = require("../services/api.js");
|
||||
const store_js_1 = require("../state/store.js");
|
||||
const toast_js_1 = require("../ui/toast.js");
|
||||
async function initSelectionView() {
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
const grid = document.getElementById('guildGrid');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (logoutBtn && cfg) {
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
window.location.href = `${cfg.baseAuth}/logout`;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const me = await api_js_1.api.me();
|
||||
if (userInfo && me?.user)
|
||||
userInfo.textContent = `${me.user.username}#${me.user.discriminator}`;
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
if (!grid || !cfg)
|
||||
return;
|
||||
grid.innerHTML = '<div class="muted">Lade Guilds...</div>';
|
||||
try {
|
||||
const data = await api_js_1.api.guilds();
|
||||
grid.innerHTML = '';
|
||||
(data.guilds || []).forEach((g) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card clickable';
|
||||
card.innerHTML = `
|
||||
<div class="row">
|
||||
<img src="${g.icon ? `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}" alt="icon" style="width:42px;height:42px;border-radius:12px;object-fit:cover;"/>
|
||||
<div>
|
||||
<div style="font-weight:700;">${g.name}</div>
|
||||
<div class="muted">ID: ${g.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;" class="pill">Zum Dashboard</div>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
const qs = g.id ? `?guildId=${encodeURIComponent(g.id)}` : '';
|
||||
window.location.href = `${cfg.baseDashboard}${qs}`;
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
if (!data.guilds?.length) {
|
||||
grid.innerHTML = '<div class="empty-state">Bot ist in keiner Guild. Bitte Bot einladen.</div>';
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
grid.innerHTML = '<div class="empty-state">Fehler beim Laden der Guilds</div>';
|
||||
(0, toast_js_1.showToast)('Guilds konnten nicht geladen werden', true);
|
||||
}
|
||||
}
|
||||
26
public/ts-build/components/modules/dynamicVoice.js
Normal file
26
public/ts-build/components/modules/dynamicVoice.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderDynamicVoiceModule = renderDynamicVoiceModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderDynamicVoiceModule(guildId) {
|
||||
const container = document.getElementById('module-dynamicvoice');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Dynamic Voice...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.dynamicVoice(guildId);
|
||||
const cfg = data?.config || data?.dynamicVoiceConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Dynamic Voice</h3>
|
||||
<p class="muted">Lobby: ${cfg.lobbyChannelId || '-'}</p>
|
||||
<p class="muted">Template: ${cfg.template || '-'}</p>
|
||||
<p class="muted">User-Limit: ${cfg.userLimit ?? '-'}</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Dynamic Voice konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden von Dynamic Voice', true);
|
||||
}
|
||||
}
|
||||
104
public/ts-build/components/modules/index.js
Normal file
104
public/ts-build/components/modules/index.js
Normal file
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initModulesSection = initModulesSection;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
const switch_js_1 = require("../../ui/switch.js");
|
||||
const welcome_js_1 = require("./welcome.js");
|
||||
const logging_js_1 = require("./logging.js");
|
||||
const reactionRoles_js_1 = require("./reactionRoles.js");
|
||||
const dynamicVoice_js_1 = require("./dynamicVoice.js");
|
||||
const statuspage_js_1 = require("./statuspage.js");
|
||||
const serverstats_js_1 = require("./serverstats.js");
|
||||
async function initModulesSection(guildId) {
|
||||
const section = document.getElementById('section-modules');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Module</h2>
|
||||
<div class="card">
|
||||
<div class="module-list" id="module-toggles"></div>
|
||||
</div>
|
||||
<div class="grid" style="margin-top:16px;">
|
||||
<div class="card" id="module-welcome"></div>
|
||||
<div class="card" id="module-logging"></div>
|
||||
<div class="card" id="module-reactionroles"></div>
|
||||
<div class="card" id="module-dynamicvoice"></div>
|
||||
<div class="card" id="module-statuspage"></div>
|
||||
<div class="card" id="module-serverstats"></div>
|
||||
</div>
|
||||
`;
|
||||
await Promise.all([
|
||||
renderModuleToggles(guildId),
|
||||
(0, welcome_js_1.renderWelcomeModule)(guildId),
|
||||
(0, logging_js_1.renderLoggingModule)(guildId),
|
||||
(0, reactionRoles_js_1.renderReactionRolesModule)(guildId),
|
||||
(0, dynamicVoice_js_1.renderDynamicVoiceModule)(guildId),
|
||||
(0, statuspage_js_1.renderStatuspageModule)(guildId),
|
||||
(0, serverstats_js_1.renderServerStatsModule)(guildId)
|
||||
]);
|
||||
}
|
||||
async function renderModuleToggles(guildId) {
|
||||
const container = document.getElementById('module-toggles');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Module...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.modules(guildId);
|
||||
const modules = data?.modules || data || {};
|
||||
container.innerHTML = '';
|
||||
const entries = [
|
||||
{ key: 'ticketsEnabled', label: 'Tickets', desc: 'Ticket-System aktivieren' },
|
||||
{ key: 'automodEnabled', label: 'Automod', desc: 'Moderations-Filter' },
|
||||
{ key: 'welcomeEnabled', label: 'Welcome', desc: 'Begrueßungsnachrichten' },
|
||||
{ key: 'musicEnabled', label: 'Musik', desc: 'Musiksteuerung' },
|
||||
{ key: 'levelingEnabled', label: 'Leveling', desc: 'XP/Level System' },
|
||||
{ key: 'statuspageEnabled', label: 'Statuspage', desc: 'Statusberichte' },
|
||||
{ key: 'serverStatsEnabled', label: 'Server Stats', desc: 'Stat-Channel' },
|
||||
{ key: 'birthdayEnabled', label: 'Birthday', desc: 'Geburtstagsmodul' },
|
||||
{ key: 'reactionRolesEnabled', label: 'Reaction Roles', desc: 'Selbstzuweisbare Rollen' },
|
||||
{ key: 'eventsEnabled', label: 'Events', desc: 'Event-Planung' },
|
||||
{ key: 'dynamicVoiceEnabled', label: 'Dynamic Voice', desc: 'Dynamische Voice Channels' }
|
||||
];
|
||||
entries.forEach((entry) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'module-item';
|
||||
row.innerHTML = `
|
||||
<div class="module-meta">
|
||||
<div class="module-title">${entry.label}</div>
|
||||
<div class="module-desc">${entry.desc}</div>
|
||||
</div>
|
||||
<div class="switch ${modules[entry.key] ? 'on' : ''}" data-key="${entry.key}"></div>
|
||||
`;
|
||||
const toggle = row.querySelector('.switch');
|
||||
toggle.addEventListener('click', async () => {
|
||||
(0, switch_js_1.setSwitch)(toggle, !(0, switch_js_1.getSwitch)(toggle));
|
||||
await saveModules(guildId);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Module konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Module', true);
|
||||
}
|
||||
}
|
||||
async function saveModules(guildId) {
|
||||
const toggles = Array.from(document.querySelectorAll('#module-toggles .switch'));
|
||||
const payload = { guildId };
|
||||
toggles.forEach((t) => {
|
||||
const key = t.dataset.key;
|
||||
if (!key)
|
||||
return;
|
||||
payload[key] = t.classList.contains('on');
|
||||
});
|
||||
try {
|
||||
await api_js_1.api.saveSettings(payload);
|
||||
(0, toast_js_1.showToast)('Module gespeichert');
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
(0, toast_js_1.showToast)('Module speichern fehlgeschlagen', true);
|
||||
}
|
||||
}
|
||||
26
public/ts-build/components/modules/logging.js
Normal file
26
public/ts-build/components/modules/logging.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderLoggingModule = renderLoggingModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderLoggingModule(guildId) {
|
||||
const container = document.getElementById('module-logging');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Logging...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.settings(guildId);
|
||||
const cfg = data?.settings?.loggingConfig || data?.loggingConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Logging</h3>
|
||||
<p class="muted">Channel: ${cfg.logChannelId || '-'}</p>
|
||||
<p class="muted">Join/Leave: ${cfg.categories?.joinLeave ? 'an' : 'aus'}</p>
|
||||
<p class="muted">System: ${cfg.categories?.system ? 'an' : 'aus'}</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Logging konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden von Logging', true);
|
||||
}
|
||||
}
|
||||
37
public/ts-build/components/modules/reactionRoles.js
Normal file
37
public/ts-build/components/modules/reactionRoles.js
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderReactionRolesModule = renderReactionRolesModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderReactionRolesModule(guildId) {
|
||||
const container = document.getElementById('module-reactionroles');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Reaction Roles...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.reactionRoles(guildId);
|
||||
const entries = data?.entries || data?.reactionRoles || [];
|
||||
container.innerHTML = '<h3 class="label">Reaction Roles</h3>';
|
||||
if (!entries.length) {
|
||||
container.innerHTML += '<div class="empty-state">Keine Reaction Roles.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
entries.slice(0, 3).forEach((e) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:750;">${e.title || e.messageId || 'Eintrag'}</div>
|
||||
<div class="muted">${e.channelId || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.appendChild(list);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Reaction Roles konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Reaction Roles', true);
|
||||
}
|
||||
}
|
||||
27
public/ts-build/components/modules/serverstats.js
Normal file
27
public/ts-build/components/modules/serverstats.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderServerStatsModule = renderServerStatsModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderServerStatsModule(guildId) {
|
||||
const container = document.getElementById('module-serverstats');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Server Stats...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.serverStats(guildId);
|
||||
const cfg = data?.config || data || {};
|
||||
const items = cfg.items || [];
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Server Stats</h3>
|
||||
<p class="muted">Kategorie: ${cfg.categoryId || '-'}</p>
|
||||
<p class="muted">Refresh: ${cfg.refresh || '-'}m</p>
|
||||
<p class="muted">Items: ${items.length}</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Server Stats konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Server Stats', true);
|
||||
}
|
||||
}
|
||||
27
public/ts-build/components/modules/statuspage.js
Normal file
27
public/ts-build/components/modules/statuspage.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderStatuspageModule = renderStatuspageModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderStatuspageModule(guildId) {
|
||||
const container = document.getElementById('module-statuspage');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Statuspage...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.statuspage(guildId);
|
||||
const cfg = data?.config || data || {};
|
||||
const services = cfg.services || [];
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Statuspage</h3>
|
||||
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
|
||||
<p class="muted">Intervall: ${cfg.interval || '-'}m</p>
|
||||
<p class="muted">Services: ${services.length}</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Statuspage konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Statuspage', true);
|
||||
}
|
||||
}
|
||||
26
public/ts-build/components/modules/welcome.js
Normal file
26
public/ts-build/components/modules/welcome.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderWelcomeModule = renderWelcomeModule;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderWelcomeModule(guildId) {
|
||||
const container = document.getElementById('module-welcome');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Welcome...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.settings(guildId);
|
||||
const cfg = data?.settings?.welcomeConfig || data?.welcomeConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Welcome</h3>
|
||||
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
|
||||
<p class="muted">Embed Titel: ${cfg.embedTitle || '-'}</p>
|
||||
<p class="muted">Status: ${data?.settings?.welcomeEnabled ? 'aktiv' : 'inaktiv'}</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Welcome konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden von Welcome', true);
|
||||
}
|
||||
}
|
||||
37
public/ts-build/components/overview.js
Normal file
37
public/ts-build/components/overview.js
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderOverview = renderOverview;
|
||||
const api_js_1 = require("../services/api.js");
|
||||
const toast_js_1 = require("../ui/toast.js");
|
||||
async function renderOverview(guildId) {
|
||||
const section = document.getElementById('section-overview');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = '<p class="muted">Lade Uebersicht...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.overview(guildId);
|
||||
const stats = data?.stats || {};
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Uebersicht</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<p class="label">Tickets offen</p>
|
||||
<p class="stat">${stats.openTickets ?? '-'}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label">Module aktiv</p>
|
||||
<p class="stat">${stats.activeModules ?? '-'}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label">Events geplant</p>
|
||||
<p class="stat">${stats.events ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Uebersicht konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Uebersicht', true);
|
||||
}
|
||||
}
|
||||
26
public/ts-build/components/settings.js
Normal file
26
public/ts-build/components/settings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderSettingsSection = renderSettingsSection;
|
||||
const api_js_1 = require("../services/api.js");
|
||||
const toast_js_1 = require("../ui/toast.js");
|
||||
async function renderSettingsSection(guildId) {
|
||||
const section = document.getElementById('section-settings');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = '<p class="muted">Lade Einstellungen...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.settings(guildId);
|
||||
const settings = data?.settings || {};
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Einstellungen</h2>
|
||||
<div class="card">
|
||||
<pre style="white-space:pre-wrap;">${JSON.stringify(settings, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Einstellungen konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Einstellungen', true);
|
||||
}
|
||||
}
|
||||
42
public/ts-build/components/tickets/automations.js
Normal file
42
public/ts-build/components/tickets/automations.js
Normal file
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderAutomations = renderAutomations;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderAutomations(guildId) {
|
||||
const container = document.getElementById('tickets-automations');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Automationen...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.automations(guildId);
|
||||
const rules = data?.rules || data || [];
|
||||
if (!rules.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine Regeln angelegt.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
rules.forEach((r) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;">${r.name || 'Regel'}</div>
|
||||
<div class="muted">${r.condition?.type || r.condition?.status || ''}</div>
|
||||
</div>
|
||||
<span class="pill">${r.active ? 'aktiv' : 'inaktiv'}</span>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Automationen</h3>';
|
||||
container.appendChild(list);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Automationen konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Automationen', true);
|
||||
}
|
||||
}
|
||||
32
public/ts-build/components/tickets/index.js
Normal file
32
public/ts-build/components/tickets/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initTicketsSection = initTicketsSection;
|
||||
const list_js_1 = require("./list.js");
|
||||
const pipeline_js_1 = require("./pipeline.js");
|
||||
const sla_js_1 = require("./sla.js");
|
||||
const automations_js_1 = require("./automations.js");
|
||||
const kb_js_1 = require("./kb.js");
|
||||
async function initTicketsSection(guildId) {
|
||||
const section = document.getElementById('section-tickets');
|
||||
if (!section)
|
||||
return;
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Tickets</h2>
|
||||
<div class="tickets-grid">
|
||||
<div class="card" id="tickets-list"></div>
|
||||
<div class="card" id="tickets-pipeline"></div>
|
||||
<div class="card" id="tickets-sla"></div>
|
||||
</div>
|
||||
<div class="grid" style="margin-top:16px;">
|
||||
<div class="card" id="tickets-automations"></div>
|
||||
<div class="card" id="tickets-kb"></div>
|
||||
</div>
|
||||
`;
|
||||
await Promise.all([
|
||||
(0, list_js_1.renderTicketList)(guildId),
|
||||
(0, pipeline_js_1.renderPipeline)(guildId),
|
||||
(0, sla_js_1.renderSla)(guildId),
|
||||
(0, automations_js_1.renderAutomations)(guildId),
|
||||
(0, kb_js_1.renderKb)(guildId)
|
||||
]);
|
||||
}
|
||||
37
public/ts-build/components/tickets/kb.js
Normal file
37
public/ts-build/components/tickets/kb.js
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderKb = renderKb;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderKb(guildId) {
|
||||
const container = document.getElementById('tickets-kb');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Knowledge Base...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.kb(guildId);
|
||||
const entries = data?.articles || data?.kb || [];
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine KB-Eintraege.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
entries.slice(0, 4).forEach((k) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:750;">${k.title || 'Artikel'}</div>
|
||||
<div class="muted">${k.keywords || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Knowledge Base</h3>';
|
||||
container.appendChild(list);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">KB konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der KB', true);
|
||||
}
|
||||
}
|
||||
43
public/ts-build/components/tickets/list.js
Normal file
43
public/ts-build/components/tickets/list.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderTicketList = renderTicketList;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderTicketList(guildId) {
|
||||
const container = document.getElementById('tickets-list');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Tickets...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.tickets(guildId);
|
||||
const tickets = data?.tickets || [];
|
||||
if (!tickets.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine Tickets</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
tickets.slice(0, 5).forEach((t) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;font-size:15px;">${t.title || t.id}</div>
|
||||
<div class="muted">${t.user || ''}</div>
|
||||
</div>
|
||||
<div class="ticket-status status-${t.status || 'open'}">${t.status || 'open'}</div>
|
||||
</div>
|
||||
<div class="muted">${t.description || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Aktuelle Tickets</h3>';
|
||||
container.appendChild(list);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Tickets konnten nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Tickets', true);
|
||||
}
|
||||
}
|
||||
37
public/ts-build/components/tickets/pipeline.js
Normal file
37
public/ts-build/components/tickets/pipeline.js
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderPipeline = renderPipeline;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderPipeline(guildId) {
|
||||
const container = document.getElementById('tickets-pipeline');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade Pipeline...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.pipeline(guildId);
|
||||
const lanes = data?.lanes || [];
|
||||
container.innerHTML = '<h3 class="label">Pipeline</h3>';
|
||||
if (!lanes.length) {
|
||||
container.innerHTML += '<div class="empty-state">Keine Pipeline-Daten</div>';
|
||||
return;
|
||||
}
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid';
|
||||
lanes.forEach((lane) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = `
|
||||
<p class="label">${lane.name || 'Lane'}</p>
|
||||
<p class="stat">${lane.count ?? 0}</p>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
container.appendChild(grid);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Pipeline konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der Pipeline', true);
|
||||
}
|
||||
}
|
||||
25
public/ts-build/components/tickets/sla.js
Normal file
25
public/ts-build/components/tickets/sla.js
Normal file
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderSla = renderSla;
|
||||
const api_js_1 = require("../../services/api.js");
|
||||
const toast_js_1 = require("../../ui/toast.js");
|
||||
async function renderSla(guildId) {
|
||||
const container = document.getElementById('tickets-sla');
|
||||
if (!container)
|
||||
return;
|
||||
container.innerHTML = '<p class="muted">Lade SLA...</p>';
|
||||
try {
|
||||
const data = await api_js_1.api.sla(guildId);
|
||||
const stats = data?.stats || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">SLA</h3>
|
||||
<p class="stat">${stats.averageResponse ?? '-'}m</p>
|
||||
<p class="muted">Durchschnittliche Antwortzeit</p>
|
||||
`;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">SLA konnte nicht geladen werden.</div>';
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden der SLA', true);
|
||||
}
|
||||
}
|
||||
69
public/ts-build/core/app.js
Normal file
69
public/ts-build/core/app.js
Normal file
@@ -0,0 +1,69 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const api_js_1 = require("../services/api.js");
|
||||
const store_js_1 = require("../state/store.js");
|
||||
const navigation_js_1 = require("../ui/navigation.js");
|
||||
const toast_js_1 = require("../ui/toast.js");
|
||||
const guildSelect_js_1 = require("../components/guildSelect.js");
|
||||
const dashboard_js_1 = require("../components/dashboard.js");
|
||||
function readConfig() {
|
||||
const root = document.getElementById('app');
|
||||
if (!root)
|
||||
throw new Error('App-Container fehlt');
|
||||
const view = root.dataset.view || 'selection';
|
||||
const baseRoot = root.dataset.baseRoot || '/ucp';
|
||||
const baseDashboard = root.dataset.baseDashboard || `${baseRoot}/dashboard`;
|
||||
const baseAuth = root.dataset.baseAuth || `${baseRoot}/auth`;
|
||||
const baseApi = root.dataset.baseApi || `${baseRoot}/api`;
|
||||
const initialGuildId = root.dataset.guildId || undefined;
|
||||
const isAdmin = root.dataset.userAdmin === 'true';
|
||||
const userLabel = root.dataset.userName
|
||||
? `${root.dataset.userName}${root.dataset.userDisc ? '#' + root.dataset.userDisc : ''}`
|
||||
: undefined;
|
||||
(0, store_js_1.initConfig)({ baseRoot, baseDashboard, baseAuth, baseApi, view, initialGuildId, isAdmin, userLabel });
|
||||
}
|
||||
async function ensureAuth() {
|
||||
try {
|
||||
const me = await api_js_1.api.me();
|
||||
if (!me?.user) {
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
window.location.href = (cfg?.baseAuth || '/auth') + '/discord';
|
||||
return null;
|
||||
}
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (userInfo && me.user)
|
||||
userInfo.textContent = `${me.user.username}#${me.user.discriminator}`;
|
||||
(0, store_js_1.setState)({ isAdmin: !!me.user?.isAdmin, userLabel: me.user ? `${me.user.username}#${me.user.discriminator}` : undefined });
|
||||
return me;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
(0, toast_js_1.showToast)('Authentifizierung fehlgeschlagen', true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function bootstrap() {
|
||||
readConfig();
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
if (!cfg)
|
||||
return;
|
||||
const sidebarRoot = document.getElementById('sidebar-root');
|
||||
if (sidebarRoot)
|
||||
(0, navigation_js_1.renderSidebar)(sidebarRoot, !!cfg.isAdmin);
|
||||
if (cfg.view === 'selection') {
|
||||
(0, guildSelect_js_1.initSelectionView)();
|
||||
}
|
||||
else {
|
||||
await ensureAuth();
|
||||
(0, dashboard_js_1.initDashboardView)();
|
||||
(0, navigation_js_1.initNavigation)((section) => {
|
||||
// Sections werden innerhalb der jeweiligen Komponenten bedient
|
||||
if (section === 'admin' && !cfg.isAdmin)
|
||||
(0, toast_js_1.showToast)('Kein Admin-Recht', true);
|
||||
});
|
||||
}
|
||||
}
|
||||
bootstrap().catch((err) => {
|
||||
console.error(err);
|
||||
(0, toast_js_1.showToast)('Fehler beim Laden', true);
|
||||
});
|
||||
74
public/ts-build/services/api.js
Normal file
74
public/ts-build/services/api.js
Normal file
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.api = void 0;
|
||||
const store_js_1 = require("../state/store.js");
|
||||
function buildUrl(path, query) {
|
||||
const cfg = (0, store_js_1.getConfig)();
|
||||
const base = cfg?.baseApi || '';
|
||||
const url = new URL(path.startsWith('http') ? path : `${base}${path}`, window.location.origin);
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null)
|
||||
return;
|
||||
url.searchParams.set(k, String(v));
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
async function request(path, options = {}) {
|
||||
const { query, headers, ...rest } = options;
|
||||
const url = buildUrl(path, query);
|
||||
const res = await fetch(url, {
|
||||
...rest,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(headers || {})
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`Request failed: ${res.status} ${text}`);
|
||||
}
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return (await res.json());
|
||||
}
|
||||
return (await res.text());
|
||||
}
|
||||
exports.api = {
|
||||
me: () => request('/me'),
|
||||
guilds: () => request('/guilds'),
|
||||
overview: (guildId) => request(`/overview`, { query: { guildId } }),
|
||||
settings: (guildId) => request(`/settings`, { query: { guildId } }),
|
||||
saveSettings: (payload) => request('/settings', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
modules: (guildId) => request(`/modules`, { query: { guildId } }),
|
||||
tickets: (guildId) => request(`/tickets`, { query: { guildId } }),
|
||||
pipeline: (guildId, filter) => request(`/tickets/pipeline`, { query: { guildId, filter } }),
|
||||
sla: (guildId, range) => request(`/tickets/sla`, { query: { guildId, range } }),
|
||||
automations: (guildId) => request(`/automations`, { query: { guildId } }),
|
||||
saveAutomation: (payload) => request(payload['id'] ? `/automations/${payload['id']}` : '/automations', {
|
||||
method: payload['id'] ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
kb: (guildId) => request(`/kb`, { query: { guildId } }),
|
||||
saveKb: (payload) => request(payload['id'] ? `/kb/${payload['id']}` : '/kb', {
|
||||
method: payload['id'] ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
reactionRoles: (guildId) => request(`/reactionroles`, { query: { guildId } }),
|
||||
saveReactionRole: (payload) => request(payload.id ? `/reactionroles/${payload.id}` : '/reactionroles', {
|
||||
method: payload.id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
events: (guildId) => request(`/events`, { query: { guildId } }),
|
||||
saveEvent: (payload) => request(payload.id ? `/events/${payload.id}` : '/events', {
|
||||
method: payload.id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
statuspage: (guildId) => request(`/statuspage`, { query: { guildId } }),
|
||||
saveStatuspage: (payload) => request('/statuspage', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
serverStats: (guildId) => request(`/serverstats`, { query: { guildId } }),
|
||||
saveServerStats: (payload) => request('/serverstats', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
dynamicVoice: (guildId) => request(`/dynamicvoice`, { query: { guildId } }),
|
||||
saveDynamicVoice: (payload) => request('/dynamicvoice', { method: 'POST', body: JSON.stringify(payload) })
|
||||
};
|
||||
32
public/ts-build/state/store.js
Normal file
32
public/ts-build/state/store.js
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initConfig = initConfig;
|
||||
exports.getConfig = getConfig;
|
||||
exports.getState = getState;
|
||||
exports.setState = setState;
|
||||
exports.subscribe = subscribe;
|
||||
let config = null;
|
||||
let state = {};
|
||||
const listeners = new Set();
|
||||
function initConfig(next) {
|
||||
config = next;
|
||||
state = {
|
||||
guildId: next.initialGuildId,
|
||||
isAdmin: next.isAdmin,
|
||||
userLabel: next.userLabel
|
||||
};
|
||||
}
|
||||
function getConfig() {
|
||||
return config;
|
||||
}
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
function setState(partial) {
|
||||
state = { ...state, ...partial };
|
||||
listeners.forEach((l) => l(state));
|
||||
}
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
27
public/ts-build/ui/modal.js
Normal file
27
public/ts-build/ui/modal.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.showModal = showModal;
|
||||
exports.hideModal = hideModal;
|
||||
let activeModal = null;
|
||||
let backdrop = null;
|
||||
function ensureBackdrop() {
|
||||
if (backdrop)
|
||||
return backdrop;
|
||||
backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.addEventListener('click', hideModal);
|
||||
document.body.appendChild(backdrop);
|
||||
return backdrop;
|
||||
}
|
||||
function showModal(content) {
|
||||
const bd = ensureBackdrop();
|
||||
if (!content.parentElement)
|
||||
bd.appendChild(content);
|
||||
activeModal = content;
|
||||
bd.classList.add('show');
|
||||
}
|
||||
function hideModal() {
|
||||
if (backdrop)
|
||||
backdrop.classList.remove('show');
|
||||
activeModal = null;
|
||||
}
|
||||
57
public/ts-build/ui/navigation.js
Normal file
57
public/ts-build/ui/navigation.js
Normal file
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.renderSidebar = renderSidebar;
|
||||
exports.initNavigation = initNavigation;
|
||||
const store_js_1 = require("../state/store.js");
|
||||
const defaultNav = [
|
||||
{ id: 'overview', label: 'Uebersicht', icon: '[*]' },
|
||||
{ id: 'tickets', label: 'Ticketsystem', icon: '[*]' },
|
||||
{ id: 'modules', label: 'Module', icon: '[*]' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: '[*]' },
|
||||
{ id: 'events', label: 'Events', icon: '[*]' },
|
||||
{ id: 'admin', label: 'Admin', icon: '[*]', requiresAdmin: true }
|
||||
];
|
||||
function renderSidebar(container, isAdmin) {
|
||||
container.innerHTML = '';
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'brand';
|
||||
brand.textContent = 'Papo Control';
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'nav';
|
||||
defaultNav.forEach((item) => {
|
||||
if (item.requiresAdmin && !isAdmin)
|
||||
return;
|
||||
const a = document.createElement('a');
|
||||
a.href = `#${item.id}`;
|
||||
a.dataset.target = item.id;
|
||||
a.innerHTML = `<span class="icon">${item.icon || ''}</span>${item.label}`;
|
||||
nav.appendChild(a);
|
||||
});
|
||||
container.appendChild(brand);
|
||||
container.appendChild(nav);
|
||||
}
|
||||
function initNavigation(onChange) {
|
||||
const navLinks = Array.from(document.querySelectorAll('.nav a'));
|
||||
const activate = (section) => {
|
||||
navLinks.forEach((link) => link.classList.toggle('active', link.dataset.target === section));
|
||||
document.querySelectorAll('.section').forEach((sec) => {
|
||||
sec.classList.toggle('active', sec.id === `section-${section}`);
|
||||
});
|
||||
(0, store_js_1.setState)({}); // trigger listeners for potential observers
|
||||
onChange(section);
|
||||
};
|
||||
navLinks.forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.dataset.target || 'overview';
|
||||
history.replaceState(null, '', `#${target}`);
|
||||
activate(target);
|
||||
});
|
||||
});
|
||||
const initial = (location.hash || '#overview').replace('#', '');
|
||||
activate(initial);
|
||||
window.addEventListener('hashchange', () => {
|
||||
const section = (location.hash || '#overview').replace('#', '');
|
||||
activate(section);
|
||||
});
|
||||
}
|
||||
19
public/ts-build/ui/switch.js
Normal file
19
public/ts-build/ui/switch.js
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.toggleSwitch = toggleSwitch;
|
||||
exports.getSwitch = getSwitch;
|
||||
exports.setSwitch = setSwitch;
|
||||
function toggleSwitch(el, force) {
|
||||
if (!el)
|
||||
return;
|
||||
const next = force === undefined ? !el.classList.contains('on') : force;
|
||||
el.classList.toggle('on', next);
|
||||
}
|
||||
function getSwitch(el) {
|
||||
return el?.classList.contains('on') ?? false;
|
||||
}
|
||||
function setSwitch(el, value) {
|
||||
if (!el)
|
||||
return;
|
||||
el.classList.toggle('on', value);
|
||||
}
|
||||
27
public/ts-build/ui/toast.js
Normal file
27
public/ts-build/ui/toast.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.showToast = showToast;
|
||||
exports.hideToast = hideToast;
|
||||
let currentTimeout = null;
|
||||
function showToast(message, isError = false, duration = 2500) {
|
||||
let toast = document.getElementById('toast-root');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'toast-root';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.className = `toast ${isError ? 'error' : ''}`;
|
||||
toast.textContent = message;
|
||||
requestAnimationFrame(() => {
|
||||
toast?.classList.add('show');
|
||||
});
|
||||
if (currentTimeout)
|
||||
window.clearTimeout(currentTimeout);
|
||||
currentTimeout = window.setTimeout(() => hideToast(), duration);
|
||||
}
|
||||
function hideToast() {
|
||||
const toast = document.getElementById('toast-root');
|
||||
if (!toast)
|
||||
return;
|
||||
toast.classList.remove('show');
|
||||
}
|
||||
1
public/ts/app.ts
Normal file
1
public/ts/app.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './core/app.js';
|
||||
22
public/ts/components/admin/index.ts
Normal file
22
public/ts/components/admin/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function initAdminSection(guildId: string) {
|
||||
const section = document.getElementById('section-admin');
|
||||
if (!section) return;
|
||||
section.innerHTML = '<p class="muted">Lade Admin-Daten...</p>';
|
||||
try {
|
||||
const data: any = await api.settings(guildId);
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Admin</h2>
|
||||
<div class="card">
|
||||
<p class="muted">Rohdaten (nur Admin):</p>
|
||||
<pre style="white-space:pre-wrap;max-height:320px;overflow:auto;">${JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Admin-Daten konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Admin-Daten', true);
|
||||
}
|
||||
}
|
||||
88
public/ts/components/dashboard.ts
Normal file
88
public/ts/components/dashboard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { api } from '../services/api.js';
|
||||
import { getConfig, getState, setState } from '../state/store.js';
|
||||
import { showToast } from '../ui/toast.js';
|
||||
import { renderOverview } from './overview.js';
|
||||
import { initTicketsSection } from './tickets/index.js';
|
||||
import { initModulesSection } from './modules/index.js';
|
||||
import { initEventsSection } from './events/index.js';
|
||||
import { initAdminSection } from './admin/index.js';
|
||||
import { renderSettingsSection } from './settings.js';
|
||||
|
||||
let overviewInterval: number | null = null;
|
||||
let ticketsInterval: number | null = null;
|
||||
|
||||
async function populateGuildSelect() {
|
||||
const select = document.getElementById('guildSelect') as HTMLSelectElement | null;
|
||||
const cfg = getConfig();
|
||||
if (!select || !cfg) return;
|
||||
select.innerHTML = `<option>Loading...</option>`;
|
||||
try {
|
||||
const data = await api.guilds();
|
||||
select.innerHTML = '';
|
||||
data.guilds.forEach((g) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.id;
|
||||
opt.textContent = g.name;
|
||||
if (g.id === cfg.initialGuildId) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
const current = select.value || cfg.initialGuildId || data.guilds[0]?.id;
|
||||
setState({ guildId: current || undefined });
|
||||
select.value = current || '';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Guilds konnten nicht geladen werden', true);
|
||||
}
|
||||
}
|
||||
|
||||
function registerGuildChange() {
|
||||
const select = document.getElementById('guildSelect') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
select.addEventListener('change', () => {
|
||||
const guildId = select.value;
|
||||
setState({ guildId });
|
||||
refreshSections();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSections() {
|
||||
const { guildId } = getState();
|
||||
if (!guildId) return;
|
||||
await renderOverview(guildId);
|
||||
await initTicketsSection(guildId);
|
||||
await initModulesSection(guildId);
|
||||
await renderSettingsSection(guildId);
|
||||
await initEventsSection(guildId);
|
||||
const cfg = getConfig();
|
||||
if (cfg?.isAdmin) {
|
||||
await initAdminSection(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
function setupPolling() {
|
||||
const { guildId } = getState();
|
||||
if (overviewInterval) window.clearInterval(overviewInterval);
|
||||
if (ticketsInterval) window.clearInterval(ticketsInterval);
|
||||
overviewInterval = window.setInterval(() => {
|
||||
const current = getState().guildId;
|
||||
if (current) renderOverview(current);
|
||||
}, 10000);
|
||||
ticketsInterval = window.setInterval(() => {
|
||||
const current = getState().guildId;
|
||||
if (current) initTicketsSection(current);
|
||||
}, 12000);
|
||||
}
|
||||
|
||||
export function initDashboardView() {
|
||||
const cfg = getConfig();
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn && cfg) {
|
||||
logoutBtn.addEventListener('click', () => (window.location.href = `${cfg.baseAuth}/logout`));
|
||||
}
|
||||
|
||||
populateGuildSelect().then(() => {
|
||||
registerGuildChange();
|
||||
refreshSections();
|
||||
setupPolling();
|
||||
});
|
||||
}
|
||||
39
public/ts/components/events/index.ts
Normal file
39
public/ts/components/events/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function initEventsSection(guildId: string) {
|
||||
const section = document.getElementById('section-events');
|
||||
if (!section) return;
|
||||
section.innerHTML = '<p class="muted">Lade Events...</p>';
|
||||
try {
|
||||
const data: any = await api.events(guildId);
|
||||
const events = data?.events || data || [];
|
||||
section.innerHTML = '<h2 class="section-title">Events</h2>';
|
||||
if (!events.length) {
|
||||
section.innerHTML += '<div class="empty-state">Keine Events geplant.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
events.forEach((ev: any) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;">${ev.title || 'Event'}</div>
|
||||
<div class="muted">${ev.date || ''}</div>
|
||||
</div>
|
||||
<span class="pill">${ev.status || 'open'}</span>
|
||||
</div>
|
||||
<div class="muted">${ev.description || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
section.appendChild(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Events konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Events', true);
|
||||
}
|
||||
}
|
||||
56
public/ts/components/guildSelect.ts
Normal file
56
public/ts/components/guildSelect.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { api } from '../services/api.js';
|
||||
import { getConfig } from '../state/store.js';
|
||||
import { showToast } from '../ui/toast.js';
|
||||
|
||||
export async function initSelectionView() {
|
||||
const cfg = getConfig();
|
||||
const grid = document.getElementById('guildGrid');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
|
||||
if (logoutBtn && cfg) {
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
window.location.href = `${cfg.baseAuth}/logout`;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await api.me();
|
||||
if (userInfo && me?.user) userInfo.textContent = `${me.user.username}#${me.user.discriminator}`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!grid || !cfg) return;
|
||||
grid.innerHTML = '<div class="muted">Lade Guilds...</div>';
|
||||
try {
|
||||
const data = await api.guilds();
|
||||
grid.innerHTML = '';
|
||||
(data.guilds || []).forEach((g) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card clickable';
|
||||
card.innerHTML = `
|
||||
<div class="row">
|
||||
<img src="${g.icon ? `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}" alt="icon" style="width:42px;height:42px;border-radius:12px;object-fit:cover;"/>
|
||||
<div>
|
||||
<div style="font-weight:700;">${g.name}</div>
|
||||
<div class="muted">ID: ${g.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;" class="pill">Zum Dashboard</div>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
const qs = g.id ? `?guildId=${encodeURIComponent(g.id)}` : '';
|
||||
window.location.href = `${cfg.baseDashboard}${qs}`;
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
if (!data.guilds?.length) {
|
||||
grid.innerHTML = '<div class="empty-state">Bot ist in keiner Guild. Bitte Bot einladen.</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
grid.innerHTML = '<div class="empty-state">Fehler beim Laden der Guilds</div>';
|
||||
showToast('Guilds konnten nicht geladen werden', true);
|
||||
}
|
||||
}
|
||||
22
public/ts/components/modules/dynamicVoice.ts
Normal file
22
public/ts/components/modules/dynamicVoice.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderDynamicVoiceModule(guildId: string) {
|
||||
const container = document.getElementById('module-dynamicvoice');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Dynamic Voice...</p>';
|
||||
try {
|
||||
const data: any = await api.dynamicVoice(guildId);
|
||||
const cfg = data?.config || data?.dynamicVoiceConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Dynamic Voice</h3>
|
||||
<p class="muted">Lobby: ${cfg.lobbyChannelId || '-'}</p>
|
||||
<p class="muted">Template: ${cfg.template || '-'}</p>
|
||||
<p class="muted">User-Limit: ${cfg.userLimit ?? '-'}</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Dynamic Voice konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden von Dynamic Voice', true);
|
||||
}
|
||||
}
|
||||
99
public/ts/components/modules/index.ts
Normal file
99
public/ts/components/modules/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
import { setSwitch, getSwitch } from '../../ui/switch.js';
|
||||
import { renderWelcomeModule } from './welcome.js';
|
||||
import { renderLoggingModule } from './logging.js';
|
||||
import { renderReactionRolesModule } from './reactionRoles.js';
|
||||
import { renderDynamicVoiceModule } from './dynamicVoice.js';
|
||||
import { renderStatuspageModule } from './statuspage.js';
|
||||
import { renderServerStatsModule } from './serverstats.js';
|
||||
|
||||
export async function initModulesSection(guildId: string) {
|
||||
const section = document.getElementById('section-modules');
|
||||
if (!section) return;
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Module</h2>
|
||||
<div class="card">
|
||||
<div class="module-list" id="module-toggles"></div>
|
||||
</div>
|
||||
<div class="grid" style="margin-top:16px;">
|
||||
<div class="card" id="module-welcome"></div>
|
||||
<div class="card" id="module-logging"></div>
|
||||
<div class="card" id="module-reactionroles"></div>
|
||||
<div class="card" id="module-dynamicvoice"></div>
|
||||
<div class="card" id="module-statuspage"></div>
|
||||
<div class="card" id="module-serverstats"></div>
|
||||
</div>
|
||||
`;
|
||||
await Promise.all([
|
||||
renderModuleToggles(guildId),
|
||||
renderWelcomeModule(guildId),
|
||||
renderLoggingModule(guildId),
|
||||
renderReactionRolesModule(guildId),
|
||||
renderDynamicVoiceModule(guildId),
|
||||
renderStatuspageModule(guildId),
|
||||
renderServerStatsModule(guildId)
|
||||
]);
|
||||
}
|
||||
|
||||
async function renderModuleToggles(guildId: string) {
|
||||
const container = document.getElementById('module-toggles');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Module...</p>';
|
||||
try {
|
||||
const data: any = await api.modules(guildId);
|
||||
const modules = data?.modules || data || {};
|
||||
container.innerHTML = '';
|
||||
const entries: Array<{ key: string; label: string; desc: string }> = [
|
||||
{ key: 'ticketsEnabled', label: 'Tickets', desc: 'Ticket-System aktivieren' },
|
||||
{ key: 'automodEnabled', label: 'Automod', desc: 'Moderations-Filter' },
|
||||
{ key: 'welcomeEnabled', label: 'Welcome', desc: 'Begrueßungsnachrichten' },
|
||||
{ key: 'musicEnabled', label: 'Musik', desc: 'Musiksteuerung' },
|
||||
{ key: 'levelingEnabled', label: 'Leveling', desc: 'XP/Level System' },
|
||||
{ key: 'statuspageEnabled', label: 'Statuspage', desc: 'Statusberichte' },
|
||||
{ key: 'serverStatsEnabled', label: 'Server Stats', desc: 'Stat-Channel' },
|
||||
{ key: 'birthdayEnabled', label: 'Birthday', desc: 'Geburtstagsmodul' },
|
||||
{ key: 'reactionRolesEnabled', label: 'Reaction Roles', desc: 'Selbstzuweisbare Rollen' },
|
||||
{ key: 'eventsEnabled', label: 'Events', desc: 'Event-Planung' },
|
||||
{ key: 'dynamicVoiceEnabled', label: 'Dynamic Voice', desc: 'Dynamische Voice Channels' }
|
||||
];
|
||||
entries.forEach((entry) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'module-item';
|
||||
row.innerHTML = `
|
||||
<div class="module-meta">
|
||||
<div class="module-title">${entry.label}</div>
|
||||
<div class="module-desc">${entry.desc}</div>
|
||||
</div>
|
||||
<div class="switch ${modules[entry.key] ? 'on' : ''}" data-key="${entry.key}"></div>
|
||||
`;
|
||||
const toggle = row.querySelector('.switch') as HTMLElement;
|
||||
toggle.addEventListener('click', async () => {
|
||||
setSwitch(toggle, !getSwitch(toggle));
|
||||
await saveModules(guildId);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Module konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Module', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModules(guildId: string) {
|
||||
const toggles = Array.from(document.querySelectorAll<HTMLElement>('#module-toggles .switch'));
|
||||
const payload: Record<string, unknown> = { guildId };
|
||||
toggles.forEach((t) => {
|
||||
const key = t.dataset.key;
|
||||
if (!key) return;
|
||||
payload[key] = t.classList.contains('on');
|
||||
});
|
||||
try {
|
||||
await api.saveSettings(payload);
|
||||
showToast('Module gespeichert');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Module speichern fehlgeschlagen', true);
|
||||
}
|
||||
}
|
||||
22
public/ts/components/modules/logging.ts
Normal file
22
public/ts/components/modules/logging.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderLoggingModule(guildId: string) {
|
||||
const container = document.getElementById('module-logging');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Logging...</p>';
|
||||
try {
|
||||
const data: any = await api.settings(guildId);
|
||||
const cfg = data?.settings?.loggingConfig || data?.loggingConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Logging</h3>
|
||||
<p class="muted">Channel: ${cfg.logChannelId || '-'}</p>
|
||||
<p class="muted">Join/Leave: ${cfg.categories?.joinLeave ? 'an' : 'aus'}</p>
|
||||
<p class="muted">System: ${cfg.categories?.system ? 'an' : 'aus'}</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Logging konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden von Logging', true);
|
||||
}
|
||||
}
|
||||
33
public/ts/components/modules/reactionRoles.ts
Normal file
33
public/ts/components/modules/reactionRoles.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderReactionRolesModule(guildId: string) {
|
||||
const container = document.getElementById('module-reactionroles');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Reaction Roles...</p>';
|
||||
try {
|
||||
const data: any = await api.reactionRoles(guildId);
|
||||
const entries = data?.entries || data?.reactionRoles || [];
|
||||
container.innerHTML = '<h3 class="label">Reaction Roles</h3>';
|
||||
if (!entries.length) {
|
||||
container.innerHTML += '<div class="empty-state">Keine Reaction Roles.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
entries.slice(0, 3).forEach((e: any) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:750;">${e.title || e.messageId || 'Eintrag'}</div>
|
||||
<div class="muted">${e.channelId || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.appendChild(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Reaction Roles konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Reaction Roles', true);
|
||||
}
|
||||
}
|
||||
23
public/ts/components/modules/serverstats.ts
Normal file
23
public/ts/components/modules/serverstats.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderServerStatsModule(guildId: string) {
|
||||
const container = document.getElementById('module-serverstats');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Server Stats...</p>';
|
||||
try {
|
||||
const data: any = await api.serverStats(guildId);
|
||||
const cfg = data?.config || data || {};
|
||||
const items = cfg.items || [];
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Server Stats</h3>
|
||||
<p class="muted">Kategorie: ${cfg.categoryId || '-'}</p>
|
||||
<p class="muted">Refresh: ${cfg.refresh || '-'}m</p>
|
||||
<p class="muted">Items: ${items.length}</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Server Stats konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Server Stats', true);
|
||||
}
|
||||
}
|
||||
23
public/ts/components/modules/statuspage.ts
Normal file
23
public/ts/components/modules/statuspage.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderStatuspageModule(guildId: string) {
|
||||
const container = document.getElementById('module-statuspage');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Statuspage...</p>';
|
||||
try {
|
||||
const data: any = await api.statuspage(guildId);
|
||||
const cfg = data?.config || data || {};
|
||||
const services = cfg.services || [];
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Statuspage</h3>
|
||||
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
|
||||
<p class="muted">Intervall: ${cfg.interval || '-'}m</p>
|
||||
<p class="muted">Services: ${services.length}</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Statuspage konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Statuspage', true);
|
||||
}
|
||||
}
|
||||
22
public/ts/components/modules/welcome.ts
Normal file
22
public/ts/components/modules/welcome.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderWelcomeModule(guildId: string) {
|
||||
const container = document.getElementById('module-welcome');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Welcome...</p>';
|
||||
try {
|
||||
const data: any = await api.settings(guildId);
|
||||
const cfg = data?.settings?.welcomeConfig || data?.welcomeConfig || {};
|
||||
container.innerHTML = `
|
||||
<h3 class="label">Welcome</h3>
|
||||
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
|
||||
<p class="muted">Embed Titel: ${cfg.embedTitle || '-'}</p>
|
||||
<p class="muted">Status: ${data?.settings?.welcomeEnabled ? 'aktiv' : 'inaktiv'}</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Welcome konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden von Welcome', true);
|
||||
}
|
||||
}
|
||||
33
public/ts/components/overview.ts
Normal file
33
public/ts/components/overview.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api } from '../services/api.js';
|
||||
import { showToast } from '../ui/toast.js';
|
||||
|
||||
export async function renderOverview(guildId: string) {
|
||||
const section = document.getElementById('section-overview');
|
||||
if (!section) return;
|
||||
section.innerHTML = '<p class="muted">Lade Uebersicht...</p>';
|
||||
try {
|
||||
const data: any = await api.overview(guildId);
|
||||
const stats = data?.stats || {};
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Uebersicht</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<p class="label">Tickets offen</p>
|
||||
<p class="stat">${stats.openTickets ?? '-'}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label">Module aktiv</p>
|
||||
<p class="stat">${stats.activeModules ?? '-'}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label">Events geplant</p>
|
||||
<p class="stat">${stats.events ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Uebersicht konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Uebersicht', true);
|
||||
}
|
||||
}
|
||||
22
public/ts/components/settings.ts
Normal file
22
public/ts/components/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from '../services/api.js';
|
||||
import { showToast } from '../ui/toast.js';
|
||||
|
||||
export async function renderSettingsSection(guildId: string) {
|
||||
const section = document.getElementById('section-settings');
|
||||
if (!section) return;
|
||||
section.innerHTML = '<p class="muted">Lade Einstellungen...</p>';
|
||||
try {
|
||||
const data: any = await api.settings(guildId);
|
||||
const settings = data?.settings || {};
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Einstellungen</h2>
|
||||
<div class="card">
|
||||
<pre style="white-space:pre-wrap;">${JSON.stringify(settings, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
section.innerHTML = '<div class="empty-state">Einstellungen konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Einstellungen', true);
|
||||
}
|
||||
}
|
||||
38
public/ts/components/tickets/automations.ts
Normal file
38
public/ts/components/tickets/automations.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderAutomations(guildId: string) {
|
||||
const container = document.getElementById('tickets-automations');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Automationen...</p>';
|
||||
try {
|
||||
const data: any = await api.automations(guildId);
|
||||
const rules = data?.rules || data || [];
|
||||
if (!rules.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine Regeln angelegt.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
rules.forEach((r: any) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;">${r.name || 'Regel'}</div>
|
||||
<div class="muted">${r.condition?.type || r.condition?.status || ''}</div>
|
||||
</div>
|
||||
<span class="pill">${r.active ? 'aktiv' : 'inaktiv'}</span>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Automationen</h3>';
|
||||
container.appendChild(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Automationen konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Automationen', true);
|
||||
}
|
||||
}
|
||||
29
public/ts/components/tickets/index.ts
Normal file
29
public/ts/components/tickets/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { renderTicketList } from './list.js';
|
||||
import { renderPipeline } from './pipeline.js';
|
||||
import { renderSla } from './sla.js';
|
||||
import { renderAutomations } from './automations.js';
|
||||
import { renderKb } from './kb.js';
|
||||
|
||||
export async function initTicketsSection(guildId: string) {
|
||||
const section = document.getElementById('section-tickets');
|
||||
if (!section) return;
|
||||
section.innerHTML = `
|
||||
<h2 class="section-title">Tickets</h2>
|
||||
<div class="tickets-grid">
|
||||
<div class="card" id="tickets-list"></div>
|
||||
<div class="card" id="tickets-pipeline"></div>
|
||||
<div class="card" id="tickets-sla"></div>
|
||||
</div>
|
||||
<div class="grid" style="margin-top:16px;">
|
||||
<div class="card" id="tickets-automations"></div>
|
||||
<div class="card" id="tickets-kb"></div>
|
||||
</div>
|
||||
`;
|
||||
await Promise.all([
|
||||
renderTicketList(guildId),
|
||||
renderPipeline(guildId),
|
||||
renderSla(guildId),
|
||||
renderAutomations(guildId),
|
||||
renderKb(guildId)
|
||||
]);
|
||||
}
|
||||
33
public/ts/components/tickets/kb.ts
Normal file
33
public/ts/components/tickets/kb.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderKb(guildId: string) {
|
||||
const container = document.getElementById('tickets-kb');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Knowledge Base...</p>';
|
||||
try {
|
||||
const data: any = await api.kb(guildId);
|
||||
const entries = data?.articles || data?.kb || [];
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine KB-Eintraege.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
entries.slice(0, 4).forEach((k: any) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:750;">${k.title || 'Artikel'}</div>
|
||||
<div class="muted">${k.keywords || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Knowledge Base</h3>';
|
||||
container.appendChild(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">KB konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der KB', true);
|
||||
}
|
||||
}
|
||||
39
public/ts/components/tickets/list.ts
Normal file
39
public/ts/components/tickets/list.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderTicketList(guildId: string) {
|
||||
const container = document.getElementById('tickets-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Tickets...</p>';
|
||||
try {
|
||||
const data: any = await api.tickets(guildId);
|
||||
const tickets = data?.tickets || [];
|
||||
if (!tickets.length) {
|
||||
container.innerHTML = '<div class="empty-state">Keine Tickets</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ticket-list';
|
||||
tickets.slice(0, 5).forEach((t: any) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ticket-item';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:750;font-size:15px;">${t.title || t.id}</div>
|
||||
<div class="muted">${t.user || ''}</div>
|
||||
</div>
|
||||
<div class="ticket-status status-${t.status || 'open'}">${t.status || 'open'}</div>
|
||||
</div>
|
||||
<div class="muted">${t.description || ''}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '<h3 class="label">Aktuelle Tickets</h3>';
|
||||
container.appendChild(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Tickets konnten nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Tickets', true);
|
||||
}
|
||||
}
|
||||
33
public/ts/components/tickets/pipeline.ts
Normal file
33
public/ts/components/tickets/pipeline.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api } from '../../services/api.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
export async function renderPipeline(guildId: string) {
|
||||
const container = document.getElementById('tickets-pipeline');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="muted">Lade Pipeline...</p>';
|
||||
try {
|
||||
const data: any = await api.pipeline(guildId);
|
||||
const lanes = data?.lanes || [];
|
||||
container.innerHTML = '<h3 class="label">Pipeline</h3>';
|
||||
if (!lanes.length) {
|
||||
container.innerHTML += '<div class="empty-state">Keine Pipeline-Daten</div>';
|
||||
return;
|
||||
}
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid';
|
||||
lanes.forEach((lane: any) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = `
|
||||
<p class="label">${lane.name || 'Lane'}</p>
|
||||
<p class="stat">${lane.count ?? 0}</p>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
container.appendChild(grid);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
container.innerHTML = '<div class="empty-state">Pipeline konnte nicht geladen werden.</div>';
|
||||
showToast('Fehler beim Laden der Pipeline', true);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user