diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6adccad..765875f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import { Watchlist } from './pages/Watchlist'; import { Admin } from './pages/Admin'; import { Branding } from './pages/Branding'; import { Growth } from './pages/Growth'; +import { Permissions } from './pages/Permissions'; +import { Panels } from './pages/Panels'; const THEME_COLORS: Record = { orange: '#f97316', @@ -68,6 +70,8 @@ function AppContent() { case 'watchlist': return ; case 'branding': return ; case 'growth': return ; + case 'permissions': return ; + case 'panels': return ; case 'admin': return ; default: return ; } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 6222975..520b870 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -6,7 +6,7 @@ import { import { LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays, ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings, - Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench + Shield, ShieldAlert, Sparkles, Tag, Ticket, TrendingUp, LayoutPanelTop, Wrench } from 'lucide-react'; import { useApp } from '../../context/AppContext'; import { AppAvatar } from '../shared/AppAvatar'; @@ -25,6 +25,7 @@ const navGroups = [ { key: 'birthday', label: 'Birthday', icon: }, { key: 'events', label: 'Events', icon: }, { key: 'reactionroles', label: 'Reaction Roles', icon: }, + { key: 'panels', label: 'Info-Panels', icon: }, ] }, { @@ -39,6 +40,7 @@ const navGroups = [ label: 'Moderation', items: [ { key: 'automod', label: 'Automod', icon: }, + { key: 'permissions', label: 'Rechte-Scanner', icon: }, { key: 'tasks', label: 'Team-Aufgaben', icon: }, { key: 'watchlist', label: 'Watchlist', icon: }, ] diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 0c9575d..98acc87 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -5,7 +5,8 @@ import type { EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState, SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField, RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats, - InviteBreakdownEntry, RecentJoinEntry + InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType, + TicketTopicConfig } from '../types'; const appConfig: AppConfig = (window as any).__PAPO__ || {}; @@ -51,6 +52,12 @@ type AppState = { growthStats: GrowthStats | null; inviteBreakdown: InviteBreakdownEntry[]; recentJoins: RecentJoinEntry[]; + permissionScan: PermissionScanResult | null; + permissionScanLoading: boolean; + infoPanels: InfoPanel[]; + panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string }; + ticketTopics: Record; + ticketTopicDraft: { topic: string; roleId: string; questions: string }; }; type AppContextType = AppState & { @@ -135,6 +142,14 @@ type AppContextType = AppState & { removeFromWatchlist: (userId: string) => Promise; loadGrowthStats: () => Promise; loadInviteBreakdown: () => Promise; + loadPermissionScan: () => Promise; + loadInfoPanels: () => Promise; + setPanelDraft: (s: any | ((prev: any) => any)) => void; + createInfoPanel: () => Promise; + deleteInfoPanel: (id: string) => Promise; + loadTicketTopics: () => Promise; + setTicketTopicDraft: (s: any | ((prev: any) => any)) => void; + saveTicketTopic: () => Promise; }; const AppContext = createContext(null); @@ -196,6 +211,12 @@ export function AppProvider({ children }: { children: ReactNode }) { const [growthStats, setGrowthStats] = useState(null); const [inviteBreakdown, setInviteBreakdown] = useState([]); const [recentJoins, setRecentJoins] = useState([]); + const [permissionScan, setPermissionScan] = useState(null); + const [permissionScanLoading, setPermissionScanLoading] = useState(false); + const [infoPanels, setInfoPanels] = useState([]); + const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' }); + const [ticketTopics, setTicketTopics] = useState>({}); + const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' }); const setSection = useCallback((key: NavKey) => { setSectionState(key); @@ -204,7 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) { useEffect(() => { const hash = window.location.hash.replace('#', '') as NavKey; - const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin']; + const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'tasks', 'watchlist', 'branding', 'growth', 'permissions', 'panels', 'admin']; if (validKeys.includes(hash)) setSectionState(hash); }, []); @@ -618,6 +639,64 @@ export function AppProvider({ children }: { children: ReactNode }) { setRecentJoins(res.recentJoins || []); } + async function loadPermissionScan() { + if (!currentGuildId) return; + setPermissionScanLoading(true); + try { + const res = await apiFetch(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`); + setPermissionScan(res.result || null); + } finally { + setPermissionScanLoading(false); + } + } + + async function loadInfoPanels() { + if (!currentGuildId) return; + const res = await apiFetch(`/panels?guildId=${encodeURIComponent(currentGuildId)}`); + setInfoPanels(res.panels || []); + } + + async function createInfoPanel() { + if (!currentGuildId || !panelDraft.channelId || !panelDraft.title) return; + const items = panelDraft.type === 'faq' + ? panelDraft.items.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 25).map((line) => { + const [q, ...rest] = line.split(':'); + return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' }; + }).filter((i) => i.question) + : undefined; + await apiFetch('/panels', { + method: 'POST', + body: JSON.stringify({ guildId: currentGuildId, channelId: panelDraft.channelId, type: panelDraft.type, title: panelDraft.title, description: panelDraft.description || undefined, items }) + }); + setPanelDraft({ type: 'rules', channelId: '', title: '', description: '', items: '' }); + setStatusMessage('Panel wurde gepostet'); + await loadInfoPanels(); + } + + async function deleteInfoPanel(id: string) { + await apiFetch(`/panels/${id}?guildId=${encodeURIComponent(currentGuildId)}`, { method: 'DELETE' }); + setStatusMessage('Panel gelöscht'); + await loadInfoPanels(); + } + + async function loadTicketTopics() { + if (!currentGuildId) return; + const res = await apiFetch(`/ticketconfig?guildId=${encodeURIComponent(currentGuildId)}`); + setTicketTopics(res.topics || {}); + } + + async function saveTicketTopic() { + if (!currentGuildId || !ticketTopicDraft.topic.trim()) return; + const questions = ticketTopicDraft.questions.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5); + await apiFetch('/ticketconfig', { + method: 'POST', + body: JSON.stringify({ guildId: currentGuildId, topic: ticketTopicDraft.topic.trim().toLowerCase(), roleId: ticketTopicDraft.roleId || undefined, questions }) + }); + setTicketTopicDraft({ topic: '', roleId: '', questions: '' }); + setStatusMessage('Ticket-Kategorie gespeichert'); + await loadTicketTopics(); + } + const handleLogout = useCallback(() => { window.location.href = `${appConfig.baseAuth || '/auth'}/logout`; }, []); @@ -633,7 +712,8 @@ export function AppProvider({ children }: { children: ReactNode }) { ticketDetail, ticketMessages, kbEditDraft, automationEditDraft, automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId, appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats, - inviteBreakdown, recentJoins, + inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels, + panelDraft, ticketTopics, ticketTopicDraft, setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin, setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData, saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats, @@ -650,6 +730,8 @@ export function AppProvider({ children }: { children: ReactNode }) { loadRegisterApps, openAppDetail, setNoteDraft, addAppNote, setTaskDraft, createTask, updateTaskStatus, deleteTask, loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown, + loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel, + loadTicketTopics, setTicketTopicDraft, saveTicketTopic, }}> {children} diff --git a/frontend/src/pages/Panels.tsx b/frontend/src/pages/Panels.tsx new file mode 100644 index 0000000..eb8c54f --- /dev/null +++ b/frontend/src/pages/Panels.tsx @@ -0,0 +1,128 @@ +import { Card, CardContent, CardHeader, Chip, Button, Input, TextArea, Separator, TextField, Label } from '@heroui/react'; +import { LayoutPanelTop, Trash2, Send, ScrollText, LifeBuoy, FileSignature, Handshake, Tags, CalendarDays, CircleHelp } from 'lucide-react'; +import { useApp } from '../context/AppContext'; +import { useGuildResources } from '../hooks/useGuildResources'; +import { SectionCard } from '../components/shared/SectionCard'; +import { ChannelSelect } from '../components/shared/ChannelSelect'; +import { EmptyState } from '../components/shared/EmptyState'; +import type { InfoPanelType } from '../types'; +import { useEffect } from 'react'; + +const TYPE_META: Record = { + rules: { label: 'Regeln', icon: }, + support: { label: 'Support', icon: }, + bewerbung: { label: 'Bewerbungen', icon: }, + partner: { label: 'Partner', icon: }, + rollen: { label: 'Rollen', icon: }, + events: { label: 'Events', icon: }, + faq: { label: 'FAQ', icon: }, +}; + +export function Panels() { + const { currentGuildId, infoPanels, loadInfoPanels, panelDraft, setPanelDraft, createInfoPanel, deleteInfoPanel } = useApp(); + const { channels } = useGuildResources(currentGuildId); + + useEffect(() => { + if (currentGuildId) loadInfoPanels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentGuildId]); + + return ( + +
+
+

Bestehende Panels ({infoPanels.length})

+
+ {infoPanels.length ? infoPanels.map((p) => ( + + +
+
+ {TYPE_META[p.type]?.icon || } +
+
+
{p.title}
+
#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}
+
+
+
+ {TYPE_META[p.type]?.label || p.type} + +
+
+
+ )) : } />} +
+
+ +
+ + +

Neues Panel erstellen

+
+ + + + + + + + + setPanelDraft((s: any) => ({ ...s, channelId: id }))} /> + + + + + setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} /> + + + {panelDraft.type !== 'faq' && ( + + +