diff --git a/frontend/src/components/shared/DiscordPreview.tsx b/frontend/src/components/shared/DiscordPreview.tsx index 30465cb..6fba23a 100644 --- a/frontend/src/components/shared/DiscordPreview.tsx +++ b/frontend/src/components/shared/DiscordPreview.tsx @@ -6,13 +6,14 @@ type Props = { title?: string; description?: string; footer?: string; + image?: string; accentColor?: string; children?: ReactNode; }; const now = () => new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); -export function DiscordPreview({ botName = 'Papo', title, description, footer, accentColor = '#5865f2', children }: Props) { +export function DiscordPreview({ botName = 'Papo', title, description, footer, image, accentColor = '#5865f2', children }: Props) { return (
@@ -26,7 +27,7 @@ export function DiscordPreview({ botName = 'Papo', title, description, footer, a Heute um {now()}
- {(title || description || footer) && ( + {(title || description || footer || image) && (
{description}
)} {footer &&
{footer}
} + {image && }
)} diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 4484f3c..0c9575d 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -4,7 +4,8 @@ import type { AppConfig, User, Guild, NavKey, TicketRecord, StatusService, EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState, SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField, - RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats + RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats, + InviteBreakdownEntry, RecentJoinEntry } from '../types'; const appConfig: AppConfig = (window as any).__PAPO__ || {}; @@ -48,6 +49,8 @@ type AppState = { taskDraft: { title: string; description: string }; watchlistEntries: WatchlistEntry[]; growthStats: GrowthStats | null; + inviteBreakdown: InviteBreakdownEntry[]; + recentJoins: RecentJoinEntry[]; }; type AppContextType = AppState & { @@ -131,6 +134,7 @@ type AppContextType = AppState & { loadWatchlist: () => Promise; removeFromWatchlist: (userId: string) => Promise; loadGrowthStats: () => Promise; + loadInviteBreakdown: () => Promise; }; const AppContext = createContext(null); @@ -165,7 +169,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' }); const [statusDraft, setStatusDraft] = useState(null); const [statsDraft, setStatsDraft] = useState(null); - const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' }); + const [reactionDraft, setReactionDraft] = useState<{ title: string; channelId: string; entries: { emoji: string; roleId: string; label: string; description: string }[] }>({ title: '', channelId: '', entries: [] }); const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null); const [registerForms, setRegisterForms] = useState([]); const [registerApps, setRegisterApps] = useState([]); @@ -190,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const [taskDraft, setTaskDraft] = useState({ title: '', description: '' }); const [watchlistEntries, setWatchlistEntries] = useState([]); const [growthStats, setGrowthStats] = useState(null); + const [inviteBreakdown, setInviteBreakdown] = useState([]); + const [recentJoins, setRecentJoins] = useState([]); const setSection = useCallback((key: NavKey) => { setSectionState(key); @@ -277,7 +283,7 @@ export function AppProvider({ children }: { children: ReactNode }) { setRegisterApps(registerAppsRes.applications || []); setTasks(tasksRes.tasks || []); setWatchlistEntries(watchlistRes.entries || []); - setReactionDraft({ title: '', channelId: '', entries: '' }); + setReactionDraft({ title: '', channelId: '', entries: [] }); await Promise.all([loadTicketData(guildId), loadAdminData()]); setStatusMessage(''); } catch { setStatusMessage('Daten konnten nicht geladen werden'); } @@ -356,11 +362,10 @@ export function AppProvider({ children }: { children: ReactNode }) { } 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); + const entries = (reactionDraft.entries as any[]).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); + setReactionDraft({ title: '', channelId: '', entries: [] }); setStatusMessage('Reaction Role gespeichert'); } @@ -606,6 +611,13 @@ export function AppProvider({ children }: { children: ReactNode }) { setGrowthStats(res.stats || null); } + async function loadInviteBreakdown() { + if (!currentGuildId) return; + const res = await apiFetch(`/growth/invites?guildId=${encodeURIComponent(currentGuildId)}`); + setInviteBreakdown(res.breakdown || []); + setRecentJoins(res.recentJoins || []); + } + const handleLogout = useCallback(() => { window.location.href = `${appConfig.baseAuth || '/auth'}/logout`; }, []); @@ -621,6 +633,7 @@ export function AppProvider({ children }: { children: ReactNode }) { ticketDetail, ticketMessages, kbEditDraft, automationEditDraft, automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId, appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats, + inviteBreakdown, recentJoins, setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin, setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData, saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats, @@ -636,7 +649,7 @@ export function AppProvider({ children }: { children: ReactNode }) { loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter, loadRegisterApps, openAppDetail, setNoteDraft, addAppNote, setTaskDraft, createTask, updateTaskStatus, deleteTask, - loadWatchlist, removeFromWatchlist, loadGrowthStats, + loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown, }}> {children} diff --git a/frontend/src/pages/Growth.tsx b/frontend/src/pages/Growth.tsx index dcf7b33..2be9f17 100644 --- a/frontend/src/pages/Growth.tsx +++ b/frontend/src/pages/Growth.tsx @@ -1,16 +1,20 @@ import { useEffect } from 'react'; -import { Card, CardContent, CardHeader } from '@heroui/react'; -import { TrendingUp, TrendingDown, Gem, Link2, Handshake } from 'lucide-react'; +import { Card, CardContent, CardHeader, Chip } from '@heroui/react'; +import { TrendingUp, TrendingDown, Gem, Link2, Handshake, Users } 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'; +import { formatDate } from '../utils/formatters'; export function Growth() { - const { currentGuildId, growthStats, loadGrowthStats } = useApp(); + const { currentGuildId, growthStats, loadGrowthStats, inviteBreakdown, recentJoins, loadInviteBreakdown } = useApp(); useEffect(() => { - if (currentGuildId) loadGrowthStats(); + if (currentGuildId) { + loadGrowthStats(); + loadInviteBreakdown(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentGuildId]); @@ -71,6 +75,64 @@ export function Growth() { + +
+ + +

Invite-Übersicht (30 Tage)

+
+ + {inviteBreakdown.length ? inviteBreakdown.map((inv) => ( +
+
+
discord.gg/{inv.code}
+
+ {inv.inviterId ? `von <@${inv.inviterId}>` : 'Unbekannter Ersteller'} · {inv.uses} Nutzungen gesamt +
+
+
+ {inv.joins30} Joins + {inv.suspiciousJoins30 > 0 && ( + {inv.suspiciousJoins30} auffällig + )} +
+
+ )) : ( +
+ + Keine Invite-Nutzung in den letzten 30 Tagen +
+ )} +
+
+ + + +

Letzte Beitritte

+
+ + {recentJoins.length ? recentJoins.map((j) => ( +
+ +
+
+ User-ID {j.userId}{j.inviterId ? ` · eingeladen von ${j.inviterId}` : ''} +
+
+ {j.inviteCode ? `discord.gg/${j.inviteCode}` : 'Unbekannter Invite'} · {formatDate(j.createdAt)} +
+
+ {j.suspicious && Verdächtig} +
+ )) : ( +
+ + Noch keine Beitritte erfasst +
+ )} +
+
+
); } diff --git a/frontend/src/pages/ReactionRoles.tsx b/frontend/src/pages/ReactionRoles.tsx index dc159f8..122a4c8 100644 --- a/frontend/src/pages/ReactionRoles.tsx +++ b/frontend/src/pages/ReactionRoles.tsx @@ -1,13 +1,29 @@ -import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react'; -import { Tag, Save, Hash, List } from 'lucide-react'; +import { useState } from 'react'; +import { Card, CardContent, CardHeader, Input, Button, Chip, TextField, Label } from '@heroui/react'; +import { Tag, Save, Plus, X } from 'lucide-react'; import { useApp } from '../context/AppContext'; import { SectionCard } from '../components/shared/SectionCard'; import { ChannelSelect } from '../components/shared/ChannelSelect'; +import { RoleSelect } from '../components/shared/RoleSelect'; import { useGuildResources } from '../hooks/useGuildResources'; export function ReactionRoles() { const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp(); - const { channels } = useGuildResources(currentGuildId); + const { channels, roles } = useGuildResources(currentGuildId); + const [entryDraft, setEntryDraft] = useState({ emoji: '', roleId: '', label: '' }); + + const addEntry = () => { + if (!entryDraft.emoji.trim() || !entryDraft.roleId) return; + setReactionDraft((s) => ({ + ...s, + entries: [...s.entries, { emoji: entryDraft.emoji.trim(), roleId: entryDraft.roleId, label: entryDraft.label.trim(), description: '' }] + })); + setEntryDraft({ emoji: '', roleId: '', label: '' }); + }; + + const removeEntry = (index: number) => { + setReactionDraft((s) => ({ ...s, entries: s.entries.filter((_: any, i: number) => i !== index) })); + }; return ( @@ -61,20 +77,56 @@ export function ReactionRoles() { /> - +
-