add dashboard pages for permission scanner, info panels, and ticket categories
Bring the command-only permission scanner, info panel builder, and ticket role/template config to the dashboard. Also fixes /settings silently dropping brandingConfig/partnerConfig/galleryConfig/imageModerationConfig/ ticketConfig/lockdownConfig on save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ import { Watchlist } from './pages/Watchlist';
|
|||||||
import { Admin } from './pages/Admin';
|
import { Admin } from './pages/Admin';
|
||||||
import { Branding } from './pages/Branding';
|
import { Branding } from './pages/Branding';
|
||||||
import { Growth } from './pages/Growth';
|
import { Growth } from './pages/Growth';
|
||||||
|
import { Permissions } from './pages/Permissions';
|
||||||
|
import { Panels } from './pages/Panels';
|
||||||
|
|
||||||
const THEME_COLORS: Record<string, string> = {
|
const THEME_COLORS: Record<string, string> = {
|
||||||
orange: '#f97316',
|
orange: '#f97316',
|
||||||
@@ -68,6 +70,8 @@ function AppContent() {
|
|||||||
case 'watchlist': return <Watchlist />;
|
case 'watchlist': return <Watchlist />;
|
||||||
case 'branding': return <Branding />;
|
case 'branding': return <Branding />;
|
||||||
case 'growth': return <Growth />;
|
case 'growth': return <Growth />;
|
||||||
|
case 'permissions': return <Permissions />;
|
||||||
|
case 'panels': return <Panels />;
|
||||||
case 'admin': return <Admin />;
|
case 'admin': return <Admin />;
|
||||||
default: return <Dashboard />;
|
default: return <Dashboard />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
||||||
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
|
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';
|
} from 'lucide-react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { AppAvatar } from '../shared/AppAvatar';
|
import { AppAvatar } from '../shared/AppAvatar';
|
||||||
@@ -25,6 +25,7 @@ const navGroups = [
|
|||||||
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
|
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
|
||||||
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
|
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
|
||||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
||||||
|
{ key: 'panels', label: 'Info-Panels', icon: <LayoutPanelTop size={18} /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,7 @@ const navGroups = [
|
|||||||
label: 'Moderation',
|
label: 'Moderation',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
||||||
|
{ key: 'permissions', label: 'Rechte-Scanner', icon: <ShieldAlert size={18} /> },
|
||||||
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
|
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
|
||||||
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
|
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||||
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
|
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
|
||||||
InviteBreakdownEntry, RecentJoinEntry
|
InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType,
|
||||||
|
TicketTopicConfig
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||||
@@ -51,6 +52,12 @@ type AppState = {
|
|||||||
growthStats: GrowthStats | null;
|
growthStats: GrowthStats | null;
|
||||||
inviteBreakdown: InviteBreakdownEntry[];
|
inviteBreakdown: InviteBreakdownEntry[];
|
||||||
recentJoins: RecentJoinEntry[];
|
recentJoins: RecentJoinEntry[];
|
||||||
|
permissionScan: PermissionScanResult | null;
|
||||||
|
permissionScanLoading: boolean;
|
||||||
|
infoPanels: InfoPanel[];
|
||||||
|
panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string };
|
||||||
|
ticketTopics: Record<string, TicketTopicConfig>;
|
||||||
|
ticketTopicDraft: { topic: string; roleId: string; questions: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppContextType = AppState & {
|
type AppContextType = AppState & {
|
||||||
@@ -135,6 +142,14 @@ type AppContextType = AppState & {
|
|||||||
removeFromWatchlist: (userId: string) => Promise<void>;
|
removeFromWatchlist: (userId: string) => Promise<void>;
|
||||||
loadGrowthStats: () => Promise<void>;
|
loadGrowthStats: () => Promise<void>;
|
||||||
loadInviteBreakdown: () => Promise<void>;
|
loadInviteBreakdown: () => Promise<void>;
|
||||||
|
loadPermissionScan: () => Promise<void>;
|
||||||
|
loadInfoPanels: () => Promise<void>;
|
||||||
|
setPanelDraft: (s: any | ((prev: any) => any)) => void;
|
||||||
|
createInfoPanel: () => Promise<void>;
|
||||||
|
deleteInfoPanel: (id: string) => Promise<void>;
|
||||||
|
loadTicketTopics: () => Promise<void>;
|
||||||
|
setTicketTopicDraft: (s: any | ((prev: any) => any)) => void;
|
||||||
|
saveTicketTopic: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppContext = createContext<AppContextType | null>(null);
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
@@ -196,6 +211,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
|
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
|
||||||
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
|
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
|
||||||
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
|
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
|
||||||
|
const [permissionScan, setPermissionScan] = useState<PermissionScanResult | null>(null);
|
||||||
|
const [permissionScanLoading, setPermissionScanLoading] = useState(false);
|
||||||
|
const [infoPanels, setInfoPanels] = useState<InfoPanel[]>([]);
|
||||||
|
const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' });
|
||||||
|
const [ticketTopics, setTicketTopics] = useState<Record<string, TicketTopicConfig>>({});
|
||||||
|
const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' });
|
||||||
|
|
||||||
const setSection = useCallback((key: NavKey) => {
|
const setSection = useCallback((key: NavKey) => {
|
||||||
setSectionState(key);
|
setSectionState(key);
|
||||||
@@ -204,7 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash.replace('#', '') as NavKey;
|
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);
|
if (validKeys.includes(hash)) setSectionState(hash);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -618,6 +639,64 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setRecentJoins(res.recentJoins || []);
|
setRecentJoins(res.recentJoins || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPermissionScan() {
|
||||||
|
if (!currentGuildId) return;
|
||||||
|
setPermissionScanLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<any>(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||||
|
setPermissionScan(res.result || null);
|
||||||
|
} finally {
|
||||||
|
setPermissionScanLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInfoPanels() {
|
||||||
|
if (!currentGuildId) return;
|
||||||
|
const res = await apiFetch<any>(`/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<any>(`/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(() => {
|
const handleLogout = useCallback(() => {
|
||||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -633,7 +712,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||||
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
|
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
|
||||||
inviteBreakdown, recentJoins,
|
inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels,
|
||||||
|
panelDraft, ticketTopics, ticketTopicDraft,
|
||||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||||
@@ -650,6 +730,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||||
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
||||||
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
|
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
|
||||||
|
loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel,
|
||||||
|
loadTicketTopics, setTicketTopicDraft, saveTicketTopic,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
128
frontend/src/pages/Panels.tsx
Normal file
128
frontend/src/pages/Panels.tsx
Normal file
@@ -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<InfoPanelType, { label: string; icon: React.ReactNode }> = {
|
||||||
|
rules: { label: 'Regeln', icon: <ScrollText size={14} /> },
|
||||||
|
support: { label: 'Support', icon: <LifeBuoy size={14} /> },
|
||||||
|
bewerbung: { label: 'Bewerbungen', icon: <FileSignature size={14} /> },
|
||||||
|
partner: { label: 'Partner', icon: <Handshake size={14} /> },
|
||||||
|
rollen: { label: 'Rollen', icon: <Tags size={14} /> },
|
||||||
|
events: { label: 'Events', icon: <CalendarDays size={14} /> },
|
||||||
|
faq: { label: 'FAQ', icon: <CircleHelp size={14} /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SectionCard title="Info-Panels" subtitle="Feste Panels für Regeln, Support, Bewerbungen, Partner, Rollen, Events und FAQ mit passenden Buttons.">
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-base font-semibold">Bestehende Panels ({infoPanels.length})</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{infoPanels.length ? infoPanels.map((p) => (
|
||||||
|
<Card key={p.id}>
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
{TYPE_META[p.type]?.icon || <LayoutPanelTop size={14} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">{p.title}</div>
|
||||||
|
<div className="text-xs text-muted">#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Chip size="sm" variant="soft">{TYPE_META[p.type]?.label || p.type}</Chip>
|
||||||
|
<Button size="sm" variant="danger-soft" onPress={() => deleteInfoPanel(p.id)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)) : <EmptyState message="Noch keine Panels erstellt" icon={<LayoutPanelTop size={24} />} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0">
|
||||||
|
<h3 className="text-base font-semibold">Neues Panel erstellen</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 p-5">
|
||||||
|
<TextField>
|
||||||
|
<Label>Typ</Label>
|
||||||
|
<select
|
||||||
|
aria-label="Panel-Typ"
|
||||||
|
className="w-full rounded-xl text-sm"
|
||||||
|
value={panelDraft.type}
|
||||||
|
onChange={(e) => setPanelDraft((s: any) => ({ ...s, type: e.target.value as InfoPanelType }))}
|
||||||
|
>
|
||||||
|
{Object.entries(TYPE_META).map(([key, meta]) => (
|
||||||
|
<option key={key} value={key}>{meta.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField>
|
||||||
|
<Label>Ziel-Kanal</Label>
|
||||||
|
<ChannelSelect options={channels} value={panelDraft.channelId} onChange={(id) => setPanelDraft((s: any) => ({ ...s, channelId: id }))} />
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField>
|
||||||
|
<Label>Titel</Label>
|
||||||
|
<Input value={panelDraft.title} onChange={(e) => setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} />
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{panelDraft.type !== 'faq' && (
|
||||||
|
<TextField>
|
||||||
|
<Label>Beschreibung</Label>
|
||||||
|
<TextArea rows={4} value={panelDraft.description} onChange={(e) => setPanelDraft((s: any) => ({ ...s, description: e.target.value }))} />
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{panelDraft.type === 'faq' && (
|
||||||
|
<TextField>
|
||||||
|
<Label>Fragen (eine „Frage: Antwort“ pro Zeile)</Label>
|
||||||
|
<TextArea
|
||||||
|
rows={5}
|
||||||
|
placeholder="Wie erstelle ich ein Ticket?: Klicke auf den Support-Button."
|
||||||
|
value={panelDraft.items}
|
||||||
|
onChange={(e) => setPanelDraft((s: any) => ({ ...s, items: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{panelDraft.type === 'support' && 'Erhält automatisch einen Ticket-Button.'}
|
||||||
|
{panelDraft.type === 'bewerbung' && 'Erhält automatisch einen Bewerben-Button, falls ein aktives Formular existiert.'}
|
||||||
|
{panelDraft.type === 'partner' && 'Erhält automatisch einen Partner-werden-Button.'}
|
||||||
|
{panelDraft.type === 'faq' && 'Erhält ein Dropdown mit den hinterlegten Fragen.'}
|
||||||
|
{['rules', 'rollen', 'events'].includes(panelDraft.type) && 'Reines Info-Embed ohne Button.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button variant="primary" onPress={createInfoPanel} isDisabled={!panelDraft.channelId || !panelDraft.title}>
|
||||||
|
<Send size={16} /> Panel posten
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/pages/Permissions.tsx
Normal file
125
frontend/src/pages/Permissions.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
|
||||||
|
import { ShieldAlert, ShieldX, Bot, Globe, MessageSquare, Users, Copy, Ban, RefreshCw, TriangleAlert } from 'lucide-react';
|
||||||
|
import { useApp } from '../context/AppContext';
|
||||||
|
import { SectionCard } from '../components/shared/SectionCard';
|
||||||
|
import { StatCard } from '../components/shared/StatCard';
|
||||||
|
import { EmptyState } from '../components/shared/EmptyState';
|
||||||
|
|
||||||
|
function EntryList({ entries, emptyMessage, icon }: { entries: { id: string; name: string }[]; emptyMessage: string; icon: React.ReactNode }) {
|
||||||
|
if (!entries.length) return <p className="py-3 text-center text-xs text-muted">{emptyMessage}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{entries.slice(0, 10).map((e) => (
|
||||||
|
<div key={e.id} className="bg-surface-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">{e.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length > 10 && <p className="pl-1 text-xs text-muted">+{entries.length - 10} weitere</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Permissions() {
|
||||||
|
const { currentGuildId, permissionScan, permissionScanLoading, loadPermissionScan } = useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentGuildId) loadPermissionScan();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentGuildId]);
|
||||||
|
|
||||||
|
const r = permissionScan;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title="Rechte-Scanner"
|
||||||
|
subtitle="Sicherheitsanalyse der Server-Rollen, Rechte und Kanäle."
|
||||||
|
action={
|
||||||
|
<Button variant="primary" onPress={() => loadPermissionScan()} isDisabled={permissionScanLoading}>
|
||||||
|
<RefreshCw size={16} className={permissionScanLoading ? 'animate-spin' : ''} /> Scan starten
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!r && !permissionScanLoading && (
|
||||||
|
<EmptyState message="Noch kein Scan durchgeführt. Klicke auf „Scan starten“." icon={<ShieldAlert size={24} />} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{r.tooManyAdmins && (
|
||||||
|
<Card className="border-danger bg-danger-soft">
|
||||||
|
<CardContent className="flex items-center gap-3 p-4 text-sm">
|
||||||
|
<TriangleAlert size={18} className="shrink-0 text-danger" />
|
||||||
|
<span>Auffällig viele Administrator-Rechte vergeben ({r.adminRoles.length} Rollen, {r.adminMemberCount} Mitglieder mit Adminrechten). Prüfe, ob das notwendig ist.</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard icon={<ShieldAlert size={18} />} label="Admin-Rollen" value={r.adminRoles.length} color={r.tooManyAdmins ? 'danger' : 'default'} />
|
||||||
|
<StatCard icon={<Ban size={18} />} label="Ban-Rollen" value={r.banRoles.length} />
|
||||||
|
<StatCard icon={<Bot size={18} />} label="Bots mit gefährlichen Rechten" value={r.dangerousBots.length} color={r.dangerousBots.length ? 'warning' : 'default'} />
|
||||||
|
<StatCard icon={<Globe size={18} />} label="Öffentliche Kanäle" value={r.publicChannels.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Admin-Rollen</h3></CardHeader>
|
||||||
|
<CardContent className="p-5"><EntryList entries={r.adminRoles} emptyMessage="Keine Admin-Rollen" icon={<ShieldAlert size={14} className="text-danger shrink-0" />} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Rollen, die bannen können</h3></CardHeader>
|
||||||
|
<CardContent className="p-5"><EntryList entries={r.banRoles} emptyMessage="Keine Ban-Rollen" icon={<Ban size={14} className="text-warning shrink-0" />} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Bots mit gefährlichen Rechten</h3></CardHeader>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
{r.dangerousBots.length ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{r.dangerousBots.slice(0, 10).map((b) => (
|
||||||
|
<div key={b.id} className="bg-surface-tertiary flex flex-col gap-1 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2"><Bot size={14} className="text-muted shrink-0" /><span className="truncate font-medium">{b.tag}</span></div>
|
||||||
|
<div className="flex flex-wrap gap-1 pl-5">
|
||||||
|
{b.perms.map((p) => <Chip key={p} size="sm" variant="soft" color="warning">{p}</Chip>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p className="py-3 text-center text-xs text-muted">Keine Bots mit gefährlichen Rechten</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">@everyone kann schreiben</h3></CardHeader>
|
||||||
|
<CardContent className="p-5"><EntryList entries={r.everyoneCanSendChannels} emptyMessage="Kein Kanal betroffen" icon={<MessageSquare size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Leere Rollen</h3></CardHeader>
|
||||||
|
<CardContent className="p-5"><EntryList entries={r.emptyRoles} emptyMessage="Keine leeren Rollen" icon={<Users size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Nutzlose Rollen</h3></CardHeader>
|
||||||
|
<CardContent className="p-5"><EntryList entries={r.uselessRoles} emptyMessage="Keine nutzlosen Rollen" icon={<ShieldX size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="xl:col-span-2">
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Doppelte Rollen (identische Rechte)</h3></CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2 p-5">
|
||||||
|
{r.duplicateRoleGroups.length ? r.duplicateRoleGroups.map((group, i) => (
|
||||||
|
<div key={i} className="bg-surface-tertiary flex flex-wrap items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<Copy size={14} className="text-muted shrink-0" />
|
||||||
|
{group.map((role) => <Chip key={role.id} size="sm" variant="soft">{role.name}</Chip>)}
|
||||||
|
</div>
|
||||||
|
)) : <p className="py-3 text-center text-xs text-muted">Keine doppelten Rollen gefunden</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
|
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
|
||||||
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight } from 'lucide-react';
|
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight, Tag, Users } from 'lucide-react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { formatDate } from '../utils/formatters';
|
import { formatDate } from '../utils/formatters';
|
||||||
import { SectionCard } from '../components/shared/SectionCard';
|
import { SectionCard } from '../components/shared/SectionCard';
|
||||||
import { StatCard } from '../components/shared/StatCard';
|
import { StatCard } from '../components/shared/StatCard';
|
||||||
import { EmptyState } from '../components/shared/EmptyState';
|
import { EmptyState } from '../components/shared/EmptyState';
|
||||||
import { TicketKanban } from '../components/shared/TicketKanban';
|
import { TicketKanban } from '../components/shared/TicketKanban';
|
||||||
|
import { RoleSelect } from '../components/shared/RoleSelect';
|
||||||
|
import { useGuildResources } from '../hooks/useGuildResources';
|
||||||
|
|
||||||
export function Tickets() {
|
export function Tickets() {
|
||||||
const {
|
const {
|
||||||
tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
|
currentGuildId, tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
|
||||||
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
|
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
|
||||||
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
|
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
|
||||||
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
|
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
|
||||||
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
|
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
|
||||||
updateAutomation, deleteAutomation, overview
|
updateAutomation, deleteAutomation, overview,
|
||||||
|
ticketTopics, loadTicketTopics, ticketTopicDraft, setTicketTopicDraft, saveTicketTopic
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const { roles } = useGuildResources(currentGuildId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentGuildId) loadTicketTopics();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentGuildId]);
|
||||||
|
|
||||||
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
|
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ export function Tickets() {
|
|||||||
<Tab id="sla">SLA</Tab>
|
<Tab id="sla">SLA</Tab>
|
||||||
<Tab id="automations">Automationen</Tab>
|
<Tab id="automations">Automationen</Tab>
|
||||||
<Tab id="kb">Knowledge Base</Tab>
|
<Tab id="kb">Knowledge Base</Tab>
|
||||||
|
<Tab id="kategorien">Kategorien</Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs.ListContainer>
|
</Tabs.ListContainer>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -350,6 +360,85 @@ export function Tickets() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ticketTab === 'kategorien' && (
|
||||||
|
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-base font-semibold">Konfigurierte Kategorien ({Object.keys(ticketTopics).length})</h3>
|
||||||
|
<p className="mb-3 text-xs text-muted">
|
||||||
|
Der Kategorie-Slug entspricht dem Ticket-Grund (z.B. <code>ban</code>, <code>help</code>, <code>feedback</code>, <code>other</code>). Ohne Konfiguration bleibt das bisherige Verhalten unverändert.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.keys(ticketTopics).length ? Object.entries(ticketTopics).map(([topic, cfg]) => (
|
||||||
|
<Card key={topic}>
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<Tag size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">{topic}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<Users size={12} />
|
||||||
|
{cfg.roleId ? roles.find((r) => r.id === cfg.roleId)?.name || cfg.roleId : 'Standard-Support-Rolle'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Chip size="sm" variant="soft">{cfg.questions?.length ?? 0} Fragen</Chip>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="tertiary"
|
||||||
|
onPress={() => setTicketTopicDraft({ topic, roleId: cfg.roleId || '', questions: (cfg.questions || []).join('\n') })}
|
||||||
|
>
|
||||||
|
<Pencil size={14} /> Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)) : <EmptyState message="Noch keine Kategorien konfiguriert" icon={<Tag size={24} />} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-5 pt-5 pb-0">
|
||||||
|
<h3 className="text-base font-semibold">Kategorie {ticketTopicDraft.topic ? 'bearbeiten' : 'anlegen'}</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 p-5">
|
||||||
|
<TextField>
|
||||||
|
<Label>Kategorie-Slug</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="z.B. help, ban, feedback, other"
|
||||||
|
value={ticketTopicDraft.topic}
|
||||||
|
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, topic: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<TextField>
|
||||||
|
<Label>Rolle, die gepingt wird</Label>
|
||||||
|
<RoleSelect options={roles} value={ticketTopicDraft.roleId} onChange={(id) => setTicketTopicDraft((s: any) => ({ ...s, roleId: id }))} placeholder="Standard-Support-Rolle" />
|
||||||
|
</TextField>
|
||||||
|
<TextField>
|
||||||
|
<Label>Fragen-Vorlage (eine pro Zeile, max. 5)</Label>
|
||||||
|
<TextArea
|
||||||
|
rows={5}
|
||||||
|
placeholder={'Was ist passiert?\nWann ist es aufgetreten?\nHast du einen Screenshot?'}
|
||||||
|
value={ticketTopicDraft.questions}
|
||||||
|
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, questions: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<p className="text-xs text-muted">Wenn Fragen hinterlegt sind, öffnet sich beim Ticket-Erstellen ein Formular statt sofort ein Kanal zu erstellen.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="primary" onPress={saveTicketTopic} isDisabled={!ticketTopicDraft.topic.trim()}>Speichern</Button>
|
||||||
|
{ticketTopicDraft.topic && (
|
||||||
|
<Button variant="tertiary" onPress={() => setTicketTopicDraft({ topic: '', roleId: '', questions: '' })}>Abbrechen</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type NavKey =
|
|||||||
| 'watchlist'
|
| 'watchlist'
|
||||||
| 'branding'
|
| 'branding'
|
||||||
| 'growth'
|
| 'growth'
|
||||||
|
| 'permissions'
|
||||||
|
| 'panels'
|
||||||
| 'admin';
|
| 'admin';
|
||||||
|
|
||||||
export type TicketRecord = {
|
export type TicketRecord = {
|
||||||
@@ -196,6 +198,38 @@ export type RecentJoinEntry = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PermissionScanResult = {
|
||||||
|
adminRoles: { id: string; name: string }[];
|
||||||
|
banRoles: { id: string; name: string }[];
|
||||||
|
dangerousBots: { id: string; tag: string; perms: string[] }[];
|
||||||
|
publicChannels: { id: string; name: string }[];
|
||||||
|
everyoneCanSendChannels: { id: string; name: string }[];
|
||||||
|
emptyRoles: { id: string; name: string }[];
|
||||||
|
duplicateRoleGroups: { id: string; name: string }[][];
|
||||||
|
uselessRoles: { id: string; name: string }[];
|
||||||
|
tooManyAdmins: boolean;
|
||||||
|
adminMemberCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfoPanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
|
||||||
|
|
||||||
|
export type InfoPanel = {
|
||||||
|
id: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
messageId?: string | null;
|
||||||
|
type: InfoPanelType;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
items?: { question: string; answer: string }[] | null;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketTopicConfig = {
|
||||||
|
roleId?: string;
|
||||||
|
questions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type MusicSession = {
|
export type MusicSession = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
nowPlaying?: { title: string; url: string } | null;
|
nowPlaying?: { title: string; url: string } | null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ButtonInteraction,
|
ButtonInteraction,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
ChatInputCommandInteraction,
|
ChatInputCommandInteraction,
|
||||||
|
Client,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
ModalSubmitInteraction,
|
ModalSubmitInteraction,
|
||||||
@@ -47,12 +48,6 @@ export class InfoPanelService {
|
|||||||
if (!interaction.customId.startsWith('panel:create:') || !interaction.guildId || !interaction.guild) return;
|
if (!interaction.customId.startsWith('panel:create:') || !interaction.guildId || !interaction.guild) return;
|
||||||
const [, , type, channelId] = interaction.customId.split(':') as [string, string, PanelType, string];
|
const [, , type, channelId] = interaction.customId.split(':') as [string, string, PanelType, string];
|
||||||
|
|
||||||
const channel = await interaction.guild.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel || !channel.isTextBased()) {
|
|
||||||
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = interaction.fields.getTextInputValue('title');
|
const title = interaction.fields.getTextInputValue('title');
|
||||||
const description = interaction.fields.getTextInputValue('description') || undefined;
|
const description = interaction.fields.getTextInputValue('description') || undefined;
|
||||||
let items: FaqItem[] | undefined;
|
let items: FaqItem[] | undefined;
|
||||||
@@ -70,15 +65,41 @@ export class InfoPanelService {
|
|||||||
.filter((i) => i.question);
|
.filter((i) => i.question);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const panel = await this.createPanel(interaction.guildId, channelId, type, title, description, items, interaction.client);
|
||||||
|
await interaction.reply({ content: `Panel wurde in <#${panel.channelId}> gepostet.`, ephemeral: true });
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPanel(
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
type: PanelType,
|
||||||
|
title: string,
|
||||||
|
description: string | undefined,
|
||||||
|
items: FaqItem[] | undefined,
|
||||||
|
client: Client
|
||||||
|
) {
|
||||||
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel || !channel.isTextBased()) throw new Error('channel not found');
|
||||||
|
|
||||||
const panel = await prisma.infoPanel.create({
|
const panel = await prisma.infoPanel.create({
|
||||||
data: { guildId: interaction.guildId, channelId, type, title, description, items: (items as any) ?? undefined }
|
data: { guildId, channelId, type, title, description, items: (items as any) ?? undefined }
|
||||||
});
|
});
|
||||||
|
|
||||||
const { embed, components } = await this.render(panel.id, type, title, description, items, interaction.guildId);
|
const { embed, components } = await this.render(panel.id, type, title, description, items, guildId);
|
||||||
const sent = await (channel as any).send({ embeds: [embed], components });
|
const sent = await (channel as any).send({ embeds: [embed], components });
|
||||||
await prisma.infoPanel.update({ where: { id: panel.id }, data: { messageId: sent.id } });
|
return prisma.infoPanel.update({ where: { id: panel.id }, data: { messageId: sent.id } });
|
||||||
|
}
|
||||||
|
|
||||||
await interaction.reply({ content: `Panel wurde in ${channel} gepostet.`, ephemeral: true });
|
public async listPanels(guildId: string) {
|
||||||
|
return prisma.infoPanel.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePanel(guildId: string, id: string) {
|
||||||
|
await prisma.infoPanel.deleteMany({ where: { id, guildId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handleComponent(interaction: ButtonInteraction | StringSelectMenuInteraction) {
|
public async handleComponent(interaction: ButtonInteraction | StringSelectMenuInteraction) {
|
||||||
|
|||||||
@@ -111,6 +111,66 @@ router.get('/growth/invites', requireAuth, async (req, res) => {
|
|||||||
res.json({ breakdown, recentJoins });
|
res.json({ breakdown, recentJoins });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/permissions/scan', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
const guild = context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
|
||||||
|
if (!guild) return res.status(404).json({ error: 'guild not found' });
|
||||||
|
const result = await context.permissionScan.scan(guild);
|
||||||
|
res.json({ result });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/panels', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
const panels = await context.infoPanels.listPanels(guildId);
|
||||||
|
res.json({ panels });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/panels', requireAuth, async (req, res) => {
|
||||||
|
const { guildId, channelId, type, title, description, items } = req.body || {};
|
||||||
|
if (!guildId || !channelId || !type || !title) return res.status(400).json({ error: 'guildId, channelId, type, title required' });
|
||||||
|
if (!context.client) return res.status(503).json({ error: 'bot not ready' });
|
||||||
|
try {
|
||||||
|
const panel = await context.infoPanels.createPanel(guildId, channelId, type, title, description || undefined, items || undefined, context.client);
|
||||||
|
res.json({ panel });
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: 'Zielkanal nicht gefunden' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/panels/:id', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
await context.infoPanels.deletePanel(guildId, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/ticketconfig', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
const topics = settingsStore.get(guildId)?.ticketConfig?.topics || {};
|
||||||
|
res.json({ topics });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ticketconfig', requireAuth, async (req, res) => {
|
||||||
|
const { guildId, topic, roleId, questions } = req.body || {};
|
||||||
|
if (!guildId || !topic) return res.status(400).json({ error: 'guildId, topic required' });
|
||||||
|
const key = String(topic).trim().toLowerCase();
|
||||||
|
const current = settingsStore.get(guildId)?.ticketConfig?.topics?.[key] || {};
|
||||||
|
await settingsStore.set(guildId, {
|
||||||
|
ticketConfig: {
|
||||||
|
topics: {
|
||||||
|
[key]: {
|
||||||
|
roleId: roleId !== undefined ? (roleId || undefined) : current.roleId,
|
||||||
|
questions: Array.isArray(questions) ? questions.filter(Boolean).slice(0, 5) : current.questions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/guild/activity', requireAuth, (req, res) => {
|
router.get('/guild/activity', requireAuth, (req, res) => {
|
||||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
@@ -968,7 +1028,13 @@ router.post('/settings', requireAuth, async (req, res) => {
|
|||||||
registerEnabled,
|
registerEnabled,
|
||||||
registerConfig,
|
registerConfig,
|
||||||
serverStatsEnabled,
|
serverStatsEnabled,
|
||||||
serverStatsConfig
|
serverStatsConfig,
|
||||||
|
lockdownConfig,
|
||||||
|
brandingConfig,
|
||||||
|
partnerConfig,
|
||||||
|
galleryConfig,
|
||||||
|
imageModerationConfig,
|
||||||
|
ticketConfig
|
||||||
} = req.body;
|
} = req.body;
|
||||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
const normalizeArray = (val: any) =>
|
const normalizeArray = (val: any) =>
|
||||||
@@ -1106,7 +1172,13 @@ router.post('/settings', requireAuth, async (req, res) => {
|
|||||||
registerEnabled: parsedRegister.enabled,
|
registerEnabled: parsedRegister.enabled,
|
||||||
registerConfig: parsedRegister,
|
registerConfig: parsedRegister,
|
||||||
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
|
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
|
||||||
serverStatsConfig: serverStatsConfig
|
serverStatsConfig: serverStatsConfig,
|
||||||
|
lockdownConfig: lockdownConfig ?? (current as any).lockdownConfig,
|
||||||
|
brandingConfig: brandingConfig ?? (current as any).brandingConfig,
|
||||||
|
partnerConfig: partnerConfig ?? (current as any).partnerConfig,
|
||||||
|
galleryConfig: galleryConfig ?? (current as any).galleryConfig,
|
||||||
|
imageModerationConfig: imageModerationConfig ?? (current as any).imageModerationConfig,
|
||||||
|
ticketConfig: ticketConfig ?? (current as any).ticketConfig
|
||||||
});
|
});
|
||||||
// Live update logging target
|
// Live update logging target
|
||||||
context.logging = new LoggingService(updated.logChannelId);
|
context.logging = new LoggingService(updated.logChannelId);
|
||||||
|
|||||||
Reference in New Issue
Block a user