feat: Dark Mode + Support Login, Registration, Music, Ticket Actions, KB/Automation CRUD, Service/Stat-Item-Management
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled

This commit is contained in:
Pepe44DEV
2026-07-01 05:08:14 +02:00
parent a484ca93c8
commit ccaf7bd4d2
4 changed files with 590 additions and 36 deletions

View File

@@ -5,9 +5,10 @@ import {
TextArea
} from '@heroui/react';
import {
Activity, AudioLines, Bot, Cable, CalendarDays, ChevronRight, Command,
Home, LifeBuoy, Logs, MessageSquare, Puzzle, RadioTower, ScanSearch,
Settings, Shield, Sparkles, Tag, Ticket, UserRound, Users, Wrench
Activity, AudioLines, Bot, Cable, CalendarDays, ChevronRight, ClipboardList,
Command, Home, LifeBuoy, LogIn, Logs, MessageSquare, Music, Pencil,
Puzzle, RadioTower, ScanSearch, Settings, Shield, Sparkles, Tag,
Ticket, Trash2, UserRound, Users, Wrench
} from 'lucide-react';
import { apiFetch } from './utils/api';
import { formatDate, guildIconUrl } from './utils/formatters';
@@ -22,7 +23,9 @@ import { ActivityTile } from './components/shared/ActivityTile';
import { LoadingSkeleton } from './components/shared/LoadingSkeleton';
import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession
} from './types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -59,6 +62,20 @@ function App() {
const [statsDraft, setStatsDraft] = useState<any>(null);
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
const [formDraft, setFormDraft] = useState({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
const [editingFormId, setEditingFormId] = useState<string | null>(null);
const [registerTab, setRegisterTab] = useState('forms');
const [musicStatus, setMusicStatus] = useState<{ activeGuilds: number; sessions: MusicSession[] }>({ activeGuilds: 0, sessions: [] });
const [kbEditDraft, setKbEditDraft] = useState<{ id: string; title: string; keywords: string; content: string } | null>(null);
const [automationEditDraft, setAutomationEditDraft] = useState<{ id: string; name: string; conditionValue: string; actionValue: string } | null>(null);
const [statusServiceDraft, setStatusServiceDraft] = useState<{ id?: string; name: string; url: string; status: string }>({ name: '', url: '', status: 'unknown' });
const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' });
const [ticketDetail, setTicketDetail] = useState<TicketRecord | null>(null);
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
if (navItems.some((item) => item.key === hash)) setSection(hash);
@@ -82,7 +99,8 @@ function App() {
setStatusMessage('Lade Daten...');
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes] = await Promise.all([
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
@@ -93,10 +111,14 @@ function App() {
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/server-stats?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`)
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
setMusicStatus(overviewRes.music || { activeGuilds: 0, sessions: [] });
setActivity(activityRes.activity || {});
setLogs(logsRes.logs || []);
setSettings(settingsRes.settings || {});
@@ -108,6 +130,9 @@ function App() {
setStatsDraft(statsRes.config || { items: [] });
setStatusDraft(statusRes.config || { services: [] });
setEvents(eventsRes.events || []);
setSupportLogin(supportLoginRes);
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
@@ -228,6 +253,136 @@ function App() {
await loadTicketData(currentGuildId);
}
async function updateKbArticle(id: string) {
if (!kbEditDraft) return;
await apiFetch(`/kb/${id}`, {
method: 'PUT',
body: JSON.stringify({ guildId: currentGuildId, title: kbEditDraft.title, keywords: kbEditDraft.keywords, content: kbEditDraft.content })
});
setKbEditDraft(null);
setStatusMessage('KB-Artikel aktualisiert');
await loadTicketData(currentGuildId);
}
async function deleteKbArticle(id: string) {
await apiFetch(`/kb/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('KB-Artikel gelöscht');
await loadTicketData(currentGuildId);
}
async function updateAutomation(id: string) {
if (!automationEditDraft) return;
await apiFetch(`/automations/${id}`, {
method: 'PUT',
body: JSON.stringify({ guildId: currentGuildId, name: automationEditDraft.name, condition: { category: automationEditDraft.conditionValue }, action: { type: 'reminder', message: automationEditDraft.actionValue }, active: true })
});
setAutomationEditDraft(null);
setStatusMessage('Automation aktualisiert');
await loadTicketData(currentGuildId);
}
async function deleteAutomation(id: string) {
await apiFetch(`/automations/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Automation gelöscht');
await loadTicketData(currentGuildId);
}
async function saveSupportLogin() {
if (!supportLogin) return;
await apiFetch('/tickets/support-login', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, ...supportLogin.config })
});
setStatusMessage('Support Login gespeichert');
await loadGuildData(currentGuildId);
}
async function saveForm() {
const fields: RegisterFormField[] = formDraft.fields.split('\n').filter(Boolean).map((line) => {
const parts = line.split('|').map((s) => s.trim());
return { label: parts[0] || 'Feld', type: (parts[1] || 'text') as any, required: parts[2] === 'required', options: parts[3] ? parts[3].split(',').map((s) => s.trim()) : undefined };
});
const body: any = { guildId: currentGuildId, name: formDraft.name, description: formDraft.description, reviewChannelId: formDraft.reviewChannelId || undefined, notifyRoleIds: formDraft.notifyRoleIds.split(',').map((s) => s.trim()).filter(Boolean), fields, isActive: true };
if (editingFormId) {
await apiFetch(`/register/forms/${editingFormId}`, { method: 'PUT', body: JSON.stringify(body) });
setStatusMessage('Formular aktualisiert');
} else {
await apiFetch('/register/forms', { method: 'POST', body: JSON.stringify(body) });
setStatusMessage('Formular erstellt');
}
setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
setEditingFormId(null);
await loadGuildData(currentGuildId);
}
async function deleteForm(id: string) {
await apiFetch(`/register/forms/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Formular gelöscht');
await loadGuildData(currentGuildId);
}
async function sendFormPanel(formId: string) {
if (!supportLogin?.config?.panelChannelId) { setStatusMessage('Bitte zuerst Support Login konfigurieren'); return; }
await apiFetch(`/register/forms/${formId}/panel`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: supportLogin.config.panelChannelId }) });
setStatusMessage('Panel gesendet');
}
async function addStatusService() {
if (!statusServiceDraft.name) return;
await apiFetch('/statuspage/service', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, name: statusServiceDraft.name, url: statusServiceDraft.url, status: statusServiceDraft.status })
});
setStatusServiceDraft({ name: '', url: '', status: 'unknown' });
setStatusMessage('Service hinzugefügt');
await loadGuildData(currentGuildId);
}
async function deleteStatusService(id: string) {
await apiFetch(`/statuspage/service/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Service entfernt');
await loadGuildData(currentGuildId);
}
async function addStatsItem() {
if (!statsItemDraft.label) return;
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
const items = [...(draft.items || []), { key: statsItemDraft.label.toLowerCase().replace(/\s+/g, '_'), label: statsItemDraft.label, type: statsItemDraft.type }];
const updated = { ...draft, items };
setStatsDraft(updated);
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
setStatsItemDraft({ label: '', type: 'members' });
setStatusMessage('Stat-Item hinzugefügt');
await loadGuildData(currentGuildId);
}
async function deleteStatsItem(index: number) {
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
const items = (draft.items || []).filter((_: any, i: number) => i !== index);
const updated = { ...draft, items };
setStatsDraft(updated);
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
setStatusMessage('Stat-Item entfernt');
await loadGuildData(currentGuildId);
}
async function loadTicketMessages(ticketId: string) {
const res = await apiFetch<any>(`/tickets/${ticketId}/messages`);
setTicketMessages(res.messages || []);
}
async function updateTicketStatus(ticketId: string, status: string) {
await apiFetch(`/tickets/${ticketId}/status`, { method: 'POST', body: JSON.stringify({ status }) });
setStatusMessage('Status aktualisiert');
await loadGuildData(currentGuildId);
}
async function closeTicket(ticketId: string) {
await apiFetch(`/tickets/${ticketId}/close`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Ticket geschlossen');
await loadGuildData(currentGuildId);
}
const selectedGuild = guilds.find((g) => g.id === currentGuildId) || null;
const moduleFlags = guildInfo?.modules || {};
@@ -418,7 +573,35 @@ function App() {
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<ListPanel title="Offene Tickets">
{tickets.length ? tickets.map((t, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.topic || 'Ticket'} · {t.status || 'open'}</div>
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold text-small">{t.topic || 'Ticket'}</div>
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
{t.status || 'open'}
</Chip>
</div>
<div className="text-tiny text-default-400">
{t.category || 'Allgemein'} · {formatDate(t.createdAt)}
{t.claimedById ? ` · Claimed` : ''}
</div>
<div className="flex gap-2">
<select
className="rounded-lg border border-default-200 bg-default-50 px-2 py-1 text-tiny outline-none"
value=""
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
>
<option value="">Status ändern</option>
<option value="open">Open</option>
<option value="in-progress">In Progress</option>
<option value="waiting">Warten auf User</option>
<option value="closed">Closed</option>
</select>
<Button size="sm" variant="flat" color="danger" onPress={() => closeTicket(t.id)}>Schließen</Button>
<Button size="sm" variant="flat" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button>
</div>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Tickets</p>}
</ListPanel>
<ListPanel title="Support-Übersicht">
@@ -426,6 +609,31 @@ function App() {
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.category || 'Allgemein'} · {formatDate(t.createdAt)}</div>
)) : <p className="text-small text-default-400">Keine Daten</p>}
</ListPanel>
{ticketDetail && (
<Card className="border border-default-100 xl:col-span-2">
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-lg font-bold">{ticketDetail.topic || 'Ticket-Details'}</h3>
<Button size="sm" variant="light" onPress={() => setTicketDetail(null)}>Schließen</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="grid grid-cols-3 gap-3 text-small">
<div><span className="text-default-400">Status:</span> {ticketDetail.status}</div>
<div><span className="text-default-400">Priorität:</span> {ticketDetail.priority || 'normal'}</div>
<div><span className="text-default-400">Kategorie:</span> {ticketDetail.category || '-'}</div>
</div>
<Separator className="my-1" />
<h4 className="text-small font-semibold">Nachrichten ({ticketMessages.length})</h4>
<div className="max-h-60 space-y-2 overflow-auto">
{ticketMessages.length ? ticketMessages.map((msg, i) => (
<div key={i} className="rounded-lg bg-default-100/40 px-3 py-2 text-small">
<span className="text-tiny text-default-400">{msg.author?.username || msg.authorId}: </span>
{msg.content || '(Embed)'}
</div>
)) : <p className="text-tiny text-default-400">Keine Nachrichten geladen</p>}
</div>
</CardContent>
</Card>
)}
</div>
)}
@@ -434,7 +642,16 @@ function App() {
{['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].map((state) => (
<ListPanel key={state} title={state.replaceAll('_', ' ')}>
{(pipeline[state] || []).length ? (pipeline[state] || []).map((t, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.topic || t.id}</div>
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
<CardContent className="p-3 text-small">
<div className="font-medium">{t.topic || t.id}</div>
<div className="flex gap-1 mt-1">
<Button size="sm" variant="flat" className="!h-6 !min-w-0 !px-2 text-tiny" onPress={() => updateTicketStatus(t.id, state === 'neu' ? 'in-progress' : state === 'in_bearbeitung' ? 'warten_auf_user' : state === 'warten_auf_user' ? 'erledigt' : 'closed')}>
{state === 'erledigt' ? 'Schließen' : 'Weiter'}
</Button>
</div>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Tickets</p>}
</ListPanel>
))}
@@ -464,17 +681,44 @@ function App() {
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
<ListPanel title="Regeln">
{automations.length ? automations.map((rule, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
{rule.name || 'Automation'} · {rule.active === false ? 'inaktiv' : 'aktiv'}
</div>
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{rule.name || 'Automation'}</div>
<Chip size="sm" variant="flat" color={rule.active !== false ? 'success' : 'default'}>
{rule.active !== false ? 'Aktiv' : 'Inaktiv'}
</Chip>
</div>
<div className="flex gap-2">
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
Bearbeiten
</Button>
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteAutomation(rule.id)}>
Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Regeln</p>}
</ListPanel>
<FormPanel title="Neue Automation">
<Input label="Name" value={automationDraft.name} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, name: v }))} />
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, conditionValue: v }))} />
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, actionValue: v }))} />
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
</FormPanel>
{automationEditDraft ? (
<FormPanel title="Automation bearbeiten">
<Input label="Name" value={automationEditDraft.name} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, name: v }))} />
<Input label="Kategorie / Zustand" value={automationEditDraft.conditionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, conditionValue: v }))} />
<TextArea label="Aktion / Nachricht" value={automationEditDraft.actionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, actionValue: v }))} />
<div className="flex gap-2">
<Button color="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
<Button variant="flat" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
</div>
</FormPanel>
) : (
<FormPanel title="Neue Automation">
<Input label="Name" value={automationDraft.name} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, name: v }))} />
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, conditionValue: v }))} />
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, actionValue: v }))} />
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
</FormPanel>
)}
</div>
)}
@@ -482,22 +726,103 @@ function App() {
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
<ListPanel title="Artikel">
{kbArticles.length ? kbArticles.map((article, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
{article.title || 'Artikel'} · {(article.keywords || []).join(', ')}
</div>
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{article.title || 'Artikel'}</div>
<Chip size="sm" variant="flat">{article.keywords?.length || 0} Keywords</Chip>
</div>
<div className="text-small text-default-400">{article.content?.slice(0, 120)}...</div>
<div className="flex gap-2">
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
Bearbeiten
</Button>
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteKbArticle(article.id)}>
Löschen
</Button>
</div>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Artikel</p>}
</ListPanel>
<FormPanel title="Neuer KB-Artikel">
<Input label="Titel" value={kbDraft.title} onValueChange={(v) => setKbDraft((s) => ({ ...s, title: v }))} />
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(v) => setKbDraft((s) => ({ ...s, keywords: v }))} />
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(v) => setKbDraft((s) => ({ ...s, content: v }))} />
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
</FormPanel>
{kbEditDraft ? (
<FormPanel title="Artikel bearbeiten">
<Input label="Titel" value={kbEditDraft.title} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, title: v }))} />
<Input label="Keywords" value={kbEditDraft.keywords} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, keywords: v }))} />
<TextArea label="Inhalt" minRows={5} value={kbEditDraft.content} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, content: v }))} />
<div className="flex gap-2">
<Button color="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
<Button variant="flat" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
</div>
</FormPanel>
) : (
<FormPanel title="Neuer KB-Artikel">
<Input label="Titel" value={kbDraft.title} onValueChange={(v) => setKbDraft((s) => ({ ...s, title: v }))} />
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(v) => setKbDraft((s) => ({ ...s, keywords: v }))} />
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(v) => setKbDraft((s) => ({ ...s, content: v }))} />
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
</FormPanel>
)}
</div>
)}
</SectionCard>
)}
{section === 'supportlogin' && (
<SectionCard title="Support Login" subtitle="Login-Panel für Supporter konfigurieren">
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
<FormPanel title="Panel-Konfiguration">
<Switch
isSelected={supportLogin?.config?.autoRefresh !== false}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
>
Auto-Refresh aktiv
</Switch>
<Input
label="Panel Channel ID"
value={supportLogin?.config?.panelChannelId || ''}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: v } } : s)}
/>
<Input
label="Titel"
value={supportLogin?.config?.title || 'Support Login'}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: v } } : s)}
/>
<TextArea
label="Beschreibung"
value={supportLogin?.config?.description || ''}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: v } } : s)}
/>
<Input
label="Login Button Label"
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: v } } : s)}
/>
<Input
label="Logout Button Label"
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: v } } : s)}
/>
<Separator className="my-1" />
<div className="flex gap-2">
<Button color="primary" onPress={saveSupportLogin}>Speichern & Panel senden</Button>
</div>
</FormPanel>
<ListPanel title="Aktive Supporter">
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
<div key={i} className="mb-2 flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
<Chip color="success" size="sm" variant="dot" />
{s.username || s.userId}
</div>
)) : <p className="text-small text-default-400">Keine aktiven Supporter</p>}
<p className="mt-3 text-tiny text-default-400">
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
</p>
</ListPanel>
</div>
</SectionCard>
)}
{section === 'automod' && (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
<div className="grid gap-4 xl:grid-cols-2">
@@ -608,10 +933,25 @@ function App() {
</FormPanel>
<ListPanel title="Services">
{((statusDraft?.services || statuspage?.services || []) as StatusService[]).length ? ((statusDraft?.services || statuspage?.services || []) as StatusService[]).map((service, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
{service.name || 'Service'} · {service.status || 'unknown'} · {service.url || ''}
</div>
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
<CardContent className="flex items-center justify-between p-3 text-small">
<div>
<span className="font-medium">{service.name || 'Service'}</span>
<span className="ml-2 text-default-400">· {service.status} · {service.url || ''}</span>
</div>
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatusService(service.id)}>
<Trash2 size={14} />
</Button>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Services</p>}
<Separator className="my-2" />
<h4 className="text-small font-semibold mb-2">Service hinzufügen</h4>
<div className="flex flex-col gap-2">
<Input placeholder="Name" value={statusServiceDraft.name} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, name: v }))} />
<Input placeholder="URL" value={statusServiceDraft.url} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, url: v }))} />
<Button size="sm" color="primary" onPress={addStatusService}>Hinzufügen</Button>
</div>
</ListPanel>
</div>
</SectionCard>
@@ -628,15 +968,159 @@ function App() {
</FormPanel>
<ListPanel title="Items">
{((statsDraft?.items || serverStats?.items || []) as any[]).length ? ((statsDraft?.items || serverStats?.items || []) as any[]).map((item, i) => (
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
{item.label || item.key || 'Stat'} · {item.type || '-'}
</div>
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
<CardContent className="flex items-center justify-between p-3 text-small">
<div>
<span className="font-medium">{item.label || item.key || 'Stat'}</span>
<span className="ml-2 text-default-400">· {item.type || '-'}</span>
</div>
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatsItem(i)}>
<Trash2 size={14} />
</Button>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Items</p>}
<Separator className="my-2" />
<h4 className="text-small font-semibold mb-2">Item hinzufügen</h4>
<div className="flex flex-col gap-2">
<Input placeholder="Label" value={statsItemDraft.label} onValueChange={(v) => setStatsItemDraft((s) => ({ ...s, label: v }))} />
<select
className="rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm outline-none"
value={statsItemDraft.type}
onChange={(e) => setStatsItemDraft((s) => ({ ...s, type: e.target.value }))}
>
<option value="members">Mitglieder</option>
<option value="channels">Channels</option>
<option value="roles">Rollen</option>
<option value="boosts">Boosts</option>
<option value="online">Online</option>
<option value="custom">Custom</option>
</select>
<Button size="sm" color="primary" onPress={addStatsItem}>Hinzufügen</Button>
</div>
</ListPanel>
</div>
</SectionCard>
)}
{section === 'register' && (
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Anträge verwalten.">
<Tabs aria-label="Register Tabs" color="primary" selectedKey={registerTab} variant="bordered" onSelectionChange={(key) => setRegisterTab(String(key))}>
<Tab key="forms" title="Formulare" />
<Tab key="apps" title="Anträge" />
</Tabs>
{registerTab === 'forms' && (
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
<ListPanel title="Bestehende Formulare">
{registerForms.length ? registerForms.map((f) => (
<Card key={f.id} className="mb-3 border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{f.name}</div>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="light" onPress={() => {
setFormDraft({ name: f.name, description: f.description || '', reviewChannelId: f.reviewChannelId || '', notifyRoleIds: (f.notifyRoleIds || []).join(', '), fields: (f.fields || []).map((fd) => `${fd.label}|${fd.type}${fd.required ? '|required' : ''}${fd.options ? '|' + fd.options.join(',') : ''}`).join('\n') });
setEditingFormId(f.id);
}}>
<Pencil size={14} />
</Button>
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteForm(f.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
<div className="text-small text-default-400">{f.description || 'Keine Beschreibung'}</div>
<div className="flex items-center gap-2 text-tiny">
<Chip size="sm" variant="flat" color={f.isActive ? 'success' : 'default'}>{f.isActive ? 'Aktiv' : 'Inaktiv'}</Chip>
<span className="text-default-400">{f.fields?.length || 0} Felder</span>
<Button size="sm" variant="flat" onPress={() => sendFormPanel(f.id)}>Panel senden</Button>
</div>
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Formulare</p>}
</ListPanel>
<FormPanel title={editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}>
<Input label="Name" value={formDraft.name} onValueChange={(v) => setFormDraft((s) => ({ ...s, name: v }))} />
<Input label="Beschreibung" value={formDraft.description} onValueChange={(v) => setFormDraft((s) => ({ ...s, description: v }))} />
<Input label="Review Channel ID" value={formDraft.reviewChannelId} onValueChange={(v) => setFormDraft((s) => ({ ...s, reviewChannelId: v }))} />
<Input label="Benachrichtigungs-Rollen (Komma-getrennt)" value={formDraft.notifyRoleIds} onValueChange={(v) => setFormDraft((s) => ({ ...s, notifyRoleIds: v }))} />
<TextArea label="Felder (label|type|required|options)" minRows={6} value={formDraft.fields} onValueChange={(v) => setFormDraft((s) => ({ ...s, fields: v }))} />
<p className="text-tiny text-default-400">
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
</p>
<div className="flex gap-2">
<Button color="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
{editingFormId && <Button variant="flat" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>Abbrechen</Button>}
</div>
</FormPanel>
</div>
)}
{registerTab === 'apps' && (
<div className="mt-5">
<ListPanel title="Eingegangene Anträge">
{registerApps.length ? registerApps.map((app) => (
<Card key={app.id} className="mb-3 border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{app.username || app.userId}</div>
<Chip size="sm" variant="flat" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
{app.status}
</Chip>
</div>
<div className="text-tiny text-default-400">{formatDate(app.createdAt)}</div>
{app.answers?.length ? (
<div className="mt-1 space-y-1">
{app.answers.map((a, i) => (
<div key={i} className="text-small">
<span className="text-default-500">{a.label || 'Frage'}: </span>
{a.value}
</div>
))}
</div>
) : null}
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine Anträge</p>}
</ListPanel>
</div>
)}
</SectionCard>
)}
{section === 'music' && (
<SectionCard title="Musik-Status" subtitle="Aktuelle Wiedergabe und Queues pro Guild.">
<div className="grid gap-4">
<div className="flex gap-4">
<StatCard icon={<Music size={18} />} label="Aktive Guilds" value={String(musicStatus.activeGuilds)} />
</div>
{musicStatus.sessions.length ? musicStatus.sessions.map((session) => (
<Card key={session.guildId} className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{session.guildId}</div>
<Chip size="sm" variant="flat">
Loop: {session.loop} · Queue: {session.queueLength}
</Chip>
</div>
{session.nowPlaying ? (
<div className="rounded-xl bg-default-100/50 px-3 py-2 text-small">
<span className="text-default-500">Jetzt läuft: </span>
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="text-primary-400 hover:underline">
{session.nowPlaying.title}
</a>
</div>
) : (
<p className="text-small text-default-400">Keine aktive Wiedergabe</p>
)}
</CardContent>
</Card>
)) : <p className="text-small text-default-400">Keine aktiven Musik-Sessions</p>}
</div>
</SectionCard>
)}
{section === 'settings' && (
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
<div className="grid gap-4 xl:grid-cols-2">

View File

@@ -1,4 +1,6 @@
import { Chip } from '@heroui/react';
import { Button, Chip } from '@heroui/react';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
type Props = {
guildName: string;
@@ -7,6 +9,13 @@ type Props = {
};
export function Topbar({ guildName, statusMessage, children }: Props) {
const [dark, setDark] = useState(() => localStorage.getItem('papo-theme') !== 'light');
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('papo-theme', dark ? 'dark' : 'light');
}, [dark]);
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -17,6 +26,9 @@ export function Topbar({ guildName, statusMessage, children }: Props) {
{statusMessage && (
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
)}
<Button isIconOnly radius="lg" size="sm" variant="light" onPress={() => setDark((d) => !d)}>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
{children}
</div>
</div>

View File

@@ -21,6 +21,7 @@ export type Guild = {
export type NavKey =
| 'overview'
| 'tickets'
| 'supportlogin'
| 'automod'
| 'welcome'
| 'dynamicvoice'
@@ -28,6 +29,8 @@ export type NavKey =
| 'reactionroles'
| 'statuspage'
| 'serverstats'
| 'register'
| 'music'
| 'settings'
| 'modules'
| 'events'
@@ -91,3 +94,55 @@ export type NavItem = {
label: string;
icon: React.ReactNode;
};
export type SupportLoginConfig = {
panelChannelId?: string;
panelMessageId?: string;
title?: string;
description?: string;
loginLabel?: string;
logoutLabel?: string;
autoRefresh?: boolean;
};
export type SupportLoginStatus = {
active: { userId: string; username?: string; loggedInAt?: string }[];
};
export type RegisterFormField = {
id?: string;
label: string;
type: 'text' | 'paragraph' | 'select' | 'multi';
required?: boolean;
placeholder?: string;
options?: string[];
};
export type RegisterForm = {
id: string;
guildId: string;
name: string;
description?: string;
reviewChannelId?: string;
notifyRoleIds?: string[];
isActive: boolean;
fields: RegisterFormField[];
createdAt?: string;
};
export type RegisterApplication = {
id: string;
formId: string;
userId: string;
username?: string;
status: 'pending' | 'approved' | 'rejected';
answers: { fieldId?: string; label?: string; value: string }[];
createdAt?: string;
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;
queueLength: number;
loop: 'off' | 'song' | 'queue';
};

View File

@@ -1,14 +1,15 @@
import React from 'react';
import {
Activity, AudioLines, CalendarDays, Home, Logs, MessageSquare,
Puzzle, RadioTower, ScanSearch, Settings, Shield, Sparkles, Tag,
Ticket, Wrench
Activity, AudioLines, CalendarDays, ClipboardList, Home,
LogIn, Music, Puzzle, RadioTower, Settings, Shield, Sparkles,
Tag, Ticket, Wrench
} from 'lucide-react';
import type { NavItem } from '../types';
export const navItems: NavItem[] = [
{ key: 'overview', label: 'Übersicht', icon: <Home size={20} /> },
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={20} /> },
{ key: 'supportlogin', label: 'Support Login', icon: <LogIn size={20} /> },
{ key: 'automod', label: 'Automod', icon: <Shield size={20} /> },
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={20} /> },
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={20} /> },
@@ -16,6 +17,8 @@ export const navItems: NavItem[] = [
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={20} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={20} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={20} /> },
{ key: 'register', label: 'Registrierung', icon: <ClipboardList size={20} /> },
{ key: 'music', label: 'Musik', icon: <Music size={20} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={20} /> },
{ key: 'modules', label: 'Module', icon: <Puzzle size={20} /> },
{ key: 'events', label: 'Events', icon: <CalendarDays size={20} /> },