add badges, weekly plans, polls, suggestions, mod-case system, branding, partner system, alt-account detection, gallery, growth tracking
Some checks failed
Deploy Discord Bot / deploy (push) Failing after -1m12s
SonarQube / sonar (push) Successful in 4s

Two feature batches plus a deploy fix:

Moderation & engagement: persistent ModCase history (retrofits ban/kick/
mute/timeout/tempban to record cases), /warn, /watch watchlist with
automatic alerts on deletions/tickets/automod hits, /case file lookup,
achievement badges (/badges, /profile) with message/streak/ticket/
birthday/event/booster triggers, /weekplan RSVP boards, /poll with
anonymous mode and scheduled auto-close, /suggest with vote/comment/
decide flow.

Branding & growth: /branding (per-guild embed color/logo/footer/bot
name/theme, applied across logging/ticket/register/embed-builder
embeds and the dashboard sidebar/accent color at runtime), two new
/setup server-type presets (Roleplay, Creator), /rules generate
(template-based rule sets), /partner apply/list/config with invite
validation and showcase posting, alt-account suspicion scoring on
join (account age, avatar, name pattern, join bursts, ban-list
similarity), /gallery submit with voting and weekly-winner scheduler,
invite-attributed join/leave tracking with a new Wachstum dashboard
page, and an expanded Admin dashboard (guild list, feature-usage
counters).

Also fixes production: the Docker container never ran `prisma migrate
deploy`, so schema changes never reached the live database. The
compose command now applies pending migrations on every start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 23:15:35 +02:00
parent 2ff54970e2
commit aa246cd7ea
69 changed files with 21111 additions and 66 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useApp } from './context/AppContext';
import { AppLayout } from './components/layout/AppLayout';
import { GuildSelect } from './pages/GuildSelect';
@@ -17,10 +18,27 @@ import { SettingsPage } from './pages/Settings';
import { ModulesPage } from './pages/Modules';
import { Events } from './pages/Events';
import { Tasks } from './pages/Tasks';
import { Watchlist } from './pages/Watchlist';
import { Admin } from './pages/Admin';
import { Branding } from './pages/Branding';
import { Growth } from './pages/Growth';
const THEME_COLORS: Record<string, string> = {
orange: '#f97316',
blue: '#3b82f6',
green: '#22c55e',
purple: '#a855f7',
red: '#ef4444'
};
function AppContent() {
const { guilds, currentGuildId, section } = useApp();
const { guilds, currentGuildId, section, settings } = useApp();
useEffect(() => {
const branding = settings.brandingConfig || {};
const color = branding.embedColor || (branding.theme ? THEME_COLORS[branding.theme] : undefined);
if (color) document.documentElement.style.setProperty('--accent', color);
}, [settings.brandingConfig]);
if (!guilds.length) {
return <GuildSelect />;
@@ -47,6 +65,9 @@ function AppContent() {
case 'modules': return <ModulesPage />;
case 'events': return <Events />;
case 'tasks': return <Tasks />;
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -5,8 +5,8 @@ import {
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
@@ -40,6 +40,7 @@ const navGroups = [
{ 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} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
]
},
{
@@ -49,29 +50,39 @@ const navGroups = [
{ 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} /> },
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },
]
},
{
label: 'System',
items: [
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'branding', label: 'Branding', icon: <Palette size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
]
}
];
export function Sidebar() {
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout } = useApp();
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout, settings } = useApp();
const [collapsed, setCollapsed] = useState(false);
const branding = settings.brandingConfig || {};
const botName = branding.botName || 'Papo';
return (
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">P</div>
{branding.logoUrl ? (
<img src={branding.logoUrl} alt={botName} className="size-8 shrink-0 rounded-xl object-cover" />
) : (
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">
{botName.trim()[0]?.toUpperCase() || 'P'}
</div>
)}
{!collapsed && (
<div className="min-w-0">
<div className="text-sm font-bold leading-tight">Papo</div>
<div className="truncate text-sm font-bold leading-tight">{botName}</div>
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
</div>
)}

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, StaffTask
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -46,6 +46,8 @@ type AppState = {
noteDraft: string;
tasks: StaffTask[];
taskDraft: { title: string; description: string };
watchlistEntries: WatchlistEntry[];
growthStats: GrowthStats | null;
};
type AppContextType = AppState & {
@@ -126,6 +128,9 @@ type AppContextType = AppState & {
createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
loadWatchlist: () => Promise<void>;
removeFromWatchlist: (userId: string) => Promise<void>;
loadGrowthStats: () => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
@@ -183,6 +188,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -233,7 +240,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes, tasksRes] = await Promise.all([
registerFormsRes, registerAppsRes, tasksRes, watchlistRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
@@ -248,7 +255,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`)
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
@@ -268,6 +276,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setWatchlistEntries(watchlistRes.entries || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
@@ -282,7 +291,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] });
setAdmin({
overview: overviewRes.overview || {},
activity: activityRes.points || [],
logs: logsRes.logs || [],
guildList: overviewRes.guildList || [],
usage: overviewRes.usage || {}
});
} catch {}
}
@@ -573,6 +588,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
await loadGuildData(currentGuildId);
}
async function loadWatchlist() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}`);
setWatchlistEntries(res.entries || []);
}
async function removeFromWatchlist(userId: string) {
await apiFetch(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
setStatusMessage('Von der Watchlist entfernt');
await loadWatchlist();
}
async function loadGrowthStats() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/growth?guildId=${encodeURIComponent(currentGuildId)}`);
setGrowthStats(res.stats || null);
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -587,7 +620,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft, tasks, taskDraft,
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -603,6 +636,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats,
}}>
{children}
</AppContext.Provider>

View File

@@ -1,10 +1,11 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Activity, Clock, Server, Terminal } from 'lucide-react';
import { Activity, Award, ClipboardList, Clock, Eye, Handshake, Server, Terminal, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate, formatDuration } from '../utils/formatters';
import { formatDate, formatDuration, guildIconUrl } from '../utils/formatters';
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
INFO: 'accent',
@@ -20,6 +21,8 @@ export function Admin() {
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 guildList: { id: string; name: string; icon?: string; memberCount: number; boostCount: number }[] = admin.guildList || [];
const usage = admin.usage || {};
const chartItems = activityPoints.slice(-12).map((p) => ({
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
@@ -35,6 +38,14 @@ export function Admin() {
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
</div>
<h3 className="mb-3 mt-6 text-sm font-semibold text-muted">Feature-Nutzung</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Award size={18} />} label="Abzeichen vergeben" value={usage.badgesAwarded ?? 0} color="accent" />
<StatCard icon={<ClipboardList size={18} />} label="Offene Aufgaben" value={usage.openTasks ?? 0} color="warning" />
<StatCard icon={<Eye size={18} />} label="Aktive Watchlist" value={usage.activeWatchlist ?? 0} color="danger" />
<StatCard icon={<Handshake size={18} />} label="Offene Partner-Anfragen" value={usage.pendingPartners ?? 0} />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
@@ -54,33 +65,61 @@ export function Admin() {
<Card>
<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">
{logs.length} Einträge
<h3 className="text-base font-semibold">Server</h3>
<Chip size="sm" variant="soft" color="accent">
{guildList.length} Guilds
</Chip>
</CardHeader>
<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" />
{guildList.length ? guildList.map((g) => (
<div key={g.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<AppAvatar size="sm" src={guildIconUrl(g as any)} name={g.name} />
<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 className="truncate font-medium">{g.name}</div>
<div className="flex items-center gap-3 text-xs text-muted">
<span className="flex items-center gap-1"><Users size={12} /> {g.memberCount}</span>
{g.boostCount > 0 && <span>💎 {g.boostCount}</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>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Terminal size={20} />
Keine Logs
<Server size={20} />
Keine Server
</div>
)}
</CardContent>
</Card>
</div>
<Card className="mt-5">
<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">
{logs.length} Einträge
</Chip>
</CardHeader>
<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 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>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Terminal size={20} />
Keine Logs
</div>
)}
</CardContent>
</Card>
</SectionCard>
);
}

View File

@@ -0,0 +1,100 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Button, Separator, TextField, Label } from '@heroui/react';
import { Palette, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
const THEMES: { key: string; label: string; color: string }[] = [
{ key: 'orange', label: 'Orange', color: '#f97316' },
{ key: 'blue', label: 'Blau', color: '#3b82f6' },
{ key: 'green', label: 'Grün', color: '#22c55e' },
{ key: 'purple', label: 'Lila', color: '#a855f7' },
{ key: 'red', label: 'Rot', color: '#ef4444' }
];
export function Branding() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const branding = settings.brandingConfig || {};
const patch = (patchValue: Record<string, any>) =>
setSettings((s) => ({ ...s, brandingConfig: { ...(s.brandingConfig || {}), ...patchValue } }));
const previewColor = branding.embedColor || THEMES.find((t) => t.key === branding.theme)?.color || '#f97316';
return (
<SectionCard title="Branding" subtitle="Passe Papo an das Erscheinungsbild deines Servers an.">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader>
<div>
<CardTitle><Palette size={16} className="inline mr-1.5" /> Branding konfigurieren</CardTitle>
<CardDescription>Farbe, Logo, Footer und Bot-Name für dieses Dashboard und Bot-Embeds.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<TextField>
<Label>Embed-Farbe (Hex)</Label>
<Input placeholder="#f97316" value={branding.embedColor || ''} onChange={(e) => patch({ embedColor: e.target.value })} />
</TextField>
<TextField>
<Label>Logo-URL</Label>
<Input placeholder="https://..." value={branding.logoUrl || ''} onChange={(e) => patch({ logoUrl: e.target.value })} />
</TextField>
<TextField>
<Label>Footer-Text</Label>
<Input value={branding.footerText || ''} onChange={(e) => patch({ footerText: e.target.value })} />
</TextField>
<TextField>
<Label>Bot-Name im Dashboard</Label>
<Input placeholder="Papo" value={branding.botName || ''} onChange={(e) => patch({ botName: e.target.value })} />
</TextField>
<div>
<Label>Theme</Label>
<div className="mt-2 flex flex-wrap gap-2">
{THEMES.map((t) => (
<button
key={t.key}
type="button"
onClick={() => patch({ theme: t.key })}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm ${branding.theme === t.key ? 'border-accent bg-accent-soft' : 'border-border'}`}
>
<span className="size-3 rounded-full" style={{ backgroundColor: t.color }} />
{t.label}
</button>
))}
</div>
</div>
<Separator />
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ brandingConfig: branding }, 'Branding gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>So sehen deine Embeds ungefähr aus.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DiscordPreview
botName={branding.botName || 'Papo'}
title="Beispiel-Embed"
description="So wirkt dein gewähltes Branding auf Nachrichten von Papo."
footer={branding.footerText}
accentColor={previewColor}
/>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader } from '@heroui/react';
import { TrendingUp, TrendingDown, Gem, Link2, Handshake } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
export function Growth() {
const { currentGuildId, growthStats, loadGrowthStats } = useApp();
useEffect(() => {
if (currentGuildId) loadGrowthStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const stats = growthStats;
const chartItems = (stats?.dailyJoins || []).map((d) => ({
label: new Date(d.day).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }),
value: d.count
}));
return (
<SectionCard title="Wachstum" subtitle="Joins, Leaves, Invites und Booster im Überblick.">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<TrendingUp size={18} />} label="Joins (7 Tage)" value={stats?.joins7 ?? 0} color="success" />
<StatCard icon={<TrendingDown size={18} />} label="Leaves (7 Tage)" value={stats?.leaves7 ?? 0} color="danger" />
<StatCard icon={<Gem size={18} />} label="Booster" value={stats?.boosts ?? 0} color="accent" />
<StatCard icon={<Handshake size={18} />} label="Joins über Partner (30T)" value={stats?.partnerJoins30 ?? 0} color="warning" />
</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">Joins (letzte 7 Tage)</h3>
</CardHeader>
<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">
<TrendingUp size={24} />
Noch keine Daten erfasst
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bester Invite (30 Tage)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
{stats?.bestInvite ? (
<div className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Link2 size={16} className="text-accent shrink-0" />
<div className="min-w-0">
<div className="font-medium">discord.gg/{stats.bestInvite.code}</div>
<div className="text-xs text-muted">{stats.bestInvite.uses} Beitritte</div>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Link2 size={20} />
Noch keine Invite-Nutzung erfasst
</div>
)}
<p className="text-xs text-muted">
Joins (30 Tage): {stats?.joins30 ?? 0} · Leaves (30 Tage): {stats?.leaves30 ?? 0}
</p>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, Button } from '@heroui/react';
import { Eye, UserMinus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
export function Watchlist() {
const { watchlistEntries, removeFromWatchlist } = useApp();
return (
<SectionCard title="Watchlist" subtitle="Beobachtete Nutzer. Papo meldet auffälliges Verhalten im Log-Kanal.">
<h3 className="mb-3 text-base font-semibold">Beobachtete Nutzer ({watchlistEntries.length})</h3>
<div className="space-y-3">
{watchlistEntries.length ? watchlistEntries.map((w) => (
<Card key={w.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="font-semibold truncate">User-ID {w.userId}</div>
<div className="text-xs text-muted truncate">{w.reason || 'Kein Grund angegeben'}</div>
<div className="text-xs text-muted">hinzugefügt von {w.addedBy} · {formatDate(w.addedAt)}</div>
</div>
<Button size="sm" variant="danger-soft" onPress={() => removeFromWatchlist(w.userId)}>
<UserMinus size={14} /> Entfernen
</Button>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Eye size={24} />
Aktuell wird niemand beobachtet.
</div>
)}
</div>
</SectionCard>
);
}

View File

@@ -35,6 +35,9 @@ export type NavKey =
| 'modules'
| 'events'
| 'tasks'
| 'watchlist'
| 'branding'
| 'growth'
| 'admin';
export type TicketRecord = {
@@ -155,6 +158,27 @@ export type StaffTask = {
updatedAt?: string;
};
export type WatchlistEntry = {
id: string;
guildId: string;
userId: string;
reason?: string | null;
addedBy: string;
addedAt?: string;
active: boolean;
};
export type GrowthStats = {
joins7: number;
leaves7: number;
joins30: number;
leaves30: number;
bestInvite: { code: string; uses: number } | null;
partnerJoins30: number;
boosts: number;
dailyJoins: { day: string; count: number }[];
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;