add badges, weekly plans, polls, suggestions, mod-case system, branding, partner system, alt-account detection, gallery, growth tracking
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:
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend/src/pages/Branding.tsx
Normal file
100
frontend/src/pages/Branding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Growth.tsx
Normal file
76
frontend/src/pages/Growth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/pages/Watchlist.tsx
Normal file
36
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user