add setup wizard, lockdown, team tasks, embed builder; fix admin dashboard
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m11s
SonarQube / sonar (push) Successful in -3s

Four new independent features: /setup (guided onboarding wizard for
module/channel/automod-strength config), /lockdown (raid protection with
per-channel permission snapshot/restore and join-kick), /task (staff task
tracker with status buttons + dashboard view), /embed create (modal-driven
embed builder with optional link button). Also fixes the Admin dashboard,
which always showed "-" because loadAdminData() double-wrapped the overview
response and discarded activity data, plus mismatched field names against
adminService's actual shape (guildCount/activeGuilds24/uptimeMs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:02:39 +02:00
parent e708cac790
commit 2ff54970e2
30 changed files with 2500 additions and 57 deletions

View File

@@ -16,6 +16,7 @@ import { MusicPage } from './pages/Music';
import { SettingsPage } from './pages/Settings';
import { ModulesPage } from './pages/Modules';
import { Events } from './pages/Events';
import { Tasks } from './pages/Tasks';
import { Admin } from './pages/Admin';
function AppContent() {
@@ -45,6 +46,7 @@ function AppContent() {
case 'settings': return <SettingsPage />;
case 'modules': return <ModulesPage />;
case 'events': return <Events />;
case 'tasks': return <Tasks />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -5,7 +5,7 @@ import {
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, Music, Puzzle, RadioTower, Settings,
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
@@ -39,6 +39,7 @@ const navGroups = [
items: [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
]
},
{

View File

@@ -4,7 +4,7 @@ import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession
RegisterApplication, MusicSession, StaffTask
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -44,6 +44,8 @@ type AppState = {
appNotes: any[];
appHistory: any[];
noteDraft: string;
tasks: StaffTask[];
taskDraft: { title: string; description: string };
};
type AppContextType = AppState & {
@@ -120,6 +122,10 @@ type AppContextType = AppState & {
openAppDetail: (id: string) => Promise<void>;
setNoteDraft: (v: string) => void;
addAppNote: () => Promise<void>;
setTaskDraft: (s: any | ((prev: any) => any)) => void;
createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
@@ -175,6 +181,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [appNotes, setAppNotes] = useState<any[]>([]);
const [appHistory, setAppHistory] = useState<any[]>([]);
const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -225,7 +233,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes] = await Promise.all([
registerFormsRes, registerAppsRes, tasksRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
@@ -239,7 +247,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`)
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
@@ -258,6 +267,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSupportLogin(supportLoginRes);
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
@@ -267,12 +277,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
async function loadAdminData() {
if (!user?.isAdmin) return;
try {
const [overviewRes, , logsRes] = await Promise.all([
const [overviewRes, activityRes, logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] });
} catch {}
}
@@ -541,6 +551,28 @@ export function AppProvider({ children }: { children: ReactNode }) {
setAppNotes(notesRes.notes || []);
}
async function createTask() {
if (!taskDraft.title.trim() || !currentGuildId) return;
await apiFetch('/tasks', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, title: taskDraft.title.trim(), description: taskDraft.description.trim() || undefined })
});
setTaskDraft({ title: '', description: '' });
setStatusMessage('Aufgabe erstellt');
await loadGuildData(currentGuildId);
}
async function updateTaskStatus(id: string, status: string) {
await apiFetch(`/tasks/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) });
await loadGuildData(currentGuildId);
}
async function deleteTask(id: string) {
await apiFetch(`/tasks/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Aufgabe gelöscht');
await loadGuildData(currentGuildId);
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -555,7 +587,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft,
appNotes, appHistory, noteDraft, tasks, taskDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -570,6 +602,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setAutomationEditDraft,
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
}}>
{children}
</AppContext.Provider>

View File

@@ -1,43 +1,54 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Wrench, Activity, Clock, Server, Terminal } from 'lucide-react';
import { Activity, Clock, Server, Terminal } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
import { StatCard } from '../components/shared/StatCard';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate, formatDuration } from '../utils/formatters';
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
INFO: 'accent',
WARN: 'warning',
ERROR: 'danger',
};
export function Admin() {
const { user, admin } = useApp();
if (!user?.isAdmin) return null;
const overview = admin.overview || {};
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
const chartItems = activityPoints.slice(-12).map((p) => ({
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
value: p.count
}));
return (
<SectionCard title="Admin" subtitle="Bot-weite Übersichten">
<div className="grid gap-5 xl:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Server size={18} />} label="Guilds" value={overview.guildCount ?? '-'} color="accent" />
<StatCard icon={<Activity size={18} />} label="Aktive Guilds (24h)" value={overview.activeGuilds24 ?? '-'} color="success" />
<StatCard icon={<Clock size={18} />} label="Uptime" value={formatDuration(overview.uptimeMs)} color="warning" />
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bot Overview</h3>
<h3 className="text-base font-semibold">Aktivität (letzte Stunden)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Server size={14} className="text-accent" />
<span className="text-muted">Guilds</span>
<CardContent className="p-5">
{chartItems.length ? (
<BarComparisonChart items={chartItems} />
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Activity size={24} />
Noch keine Aktivität erfasst
</div>
<span className="font-semibold">{admin.overview?.guilds ?? '-'}</span>
</div>
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Activity size={14} className="text-success" />
<span className="text-muted">Aktive Guilds (24h)</span>
</div>
<span className="font-semibold">{admin.overview?.activeGuilds ?? '-'}</span>
</div>
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Clock size={14} className="text-warning" />
<span className="text-muted">Uptime</span>
</div>
<span className="font-semibold">{admin.overview?.uptime ?? '-'}</span>
</div>
)}
</CardContent>
</Card>
@@ -45,15 +56,19 @@ export function Admin() {
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
<Chip size="sm" variant="soft" color="warning">
{(admin.logs || []).length} Einträge
{logs.length} Einträge
</Chip>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(admin.logs || []).length ? (admin.logs || []).slice(0, 20).map((log, i) => (
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{logs.length ? logs.slice(0, 30).map((log, i) => (
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
<div className="min-w-0">
<p className="text-muted">{log.message || '-'}</p>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip>
{log.category && <span className="text-xs text-muted">{log.category}</span>}
</div>
<p className="mt-1 text-muted">{log.message || '-'}</p>
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
</div>
</div>

View File

@@ -0,0 +1,80 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, TextField, Label } from '@heroui/react';
import { ListChecks, Trash2, Plus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
const STATUS_LABELS: Record<string, string> = { open: 'Offen', in_progress: 'In Bearbeitung', done: 'Erledigt' };
const STATUS_COLORS: Record<string, 'warning' | 'accent' | 'success'> = { open: 'warning', in_progress: 'accent', done: 'success' };
export function Tasks() {
const { tasks, taskDraft, setTaskDraft, createTask, updateTaskStatus, deleteTask } = useApp();
return (
<SectionCard title="Team-Aufgaben" subtitle="Aufgaben für dein Team erstellen und Status pflegen.">
<div className="grid gap-5 xl:grid-cols-[1fr_360px]">
<div>
<h3 className="mb-3 text-base font-semibold">Aufgaben ({tasks.length})</h3>
<div className="space-y-3">
{tasks.length ? tasks.map((t) => (
<Card key={t.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold truncate">{t.title}</div>
<div className="text-xs text-muted">von {t.createdByTag} · {formatDate(t.createdAt)}</div>
</div>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteTask(t.id)}>
<Trash2 size={14} />
</Button>
</div>
{t.description && <p className="text-sm text-muted">{t.description}</p>}
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-xl px-3 py-2 text-sm"
value={t.status}
onChange={(e) => updateTaskStatus(t.id, e.target.value)}
>
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="done">Erledigt</option>
</select>
<Chip size="sm" variant="soft" color={STATUS_COLORS[t.status] || 'warning'}>
{STATUS_LABELS[t.status] || t.status}
</Chip>
{t.assigneeId && <span className="text-xs text-muted">Zugewiesen an User-ID {t.assigneeId}</span>}
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<ListChecks size={24} />
Keine Aufgaben
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neue Aufgabe</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input value={taskDraft.title} onChange={(e) => setTaskDraft((s) => ({ ...s, title: e.target.value }))} />
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={taskDraft.description} onChange={(e) => setTaskDraft((s) => ({ ...s, description: e.target.value }))} />
</TextField>
<p className="text-xs text-muted">Eine zuständige Person lässt sich aktuell nur über <code>/task create</code> in Discord zuweisen.</p>
<Button variant="primary" onPress={createTask} isDisabled={!taskDraft.title.trim()}>
<Plus size={16} /> Aufgabe erstellen
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -34,6 +34,7 @@ export type NavKey =
| 'settings'
| 'modules'
| 'events'
| 'tasks'
| 'admin';
export type TicketRecord = {
@@ -141,6 +142,19 @@ export type RegisterApplication = {
createdAt?: string;
};
export type StaffTask = {
id: string;
guildId: string;
title: string;
description?: string | null;
status: 'open' | 'in_progress' | 'done';
assigneeId?: string | null;
createdBy: string;
createdByTag: string;
createdAt?: string;
updatedAt?: string;
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;

View File

@@ -7,6 +7,19 @@ export function formatDate(value?: string | number | null) {
return `${date.toLocaleDateString('de-DE')} ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
export function formatDuration(ms?: number | null) {
if (!ms || ms < 0) return '-';
const totalMinutes = Math.floor(ms / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes || !parts.length) parts.push(`${minutes}m`);
return parts.join(' ');
}
export function guildIconUrl(guild?: Guild | null) {
if (!guild) return undefined;
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;