add setup wizard, lockdown, team tasks, embed builder; fix admin dashboard
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:
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
frontend/src/pages/Tasks.tsx
Normal file
80
frontend/src/pages/Tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user