import { useEffect, useRef, useState } from 'react'; import { apiGet, apiPut, apiPost, apiDelete } from '../api.js'; import EmbedComposer from '../components/EmbedComposer.jsx'; import { IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport, IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam, IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX, IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh, IconSearch, } from '../icons.jsx'; import { Markdown } from '../markdown.jsx'; const API_SCOPES = [ { id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' }, { id: 'dm', label: 'dm', hint: 'Direktnachrichten senden' }, { id: 'roles', label: 'roles', hint: 'Rollen geben/nehmen' }, { id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' }, ]; // Bereiche der Seitenleiste — bei 15 Tabs findet man ohne Gruppierung nichts mehr. const TAB_SECTIONS = [ { id: 'ueberblick', label: 'Überblick' }, { id: 'auftritt', label: 'Auftritt' }, { id: 'inhalte', label: 'Inhalte' }, { id: 'community', label: 'Community' }, { id: 'technik', label: 'Technik' }, { id: 'zugang', label: 'Zugang' }, ]; // `find` sind zusätzliche Suchbegriffe: was jemand tippt, wenn er den Tab-Namen // nicht kennt („geburtstag" → Community, „willkommen" → Support). const TABS = [ { id: 'status', Icon: IconStatus, label: 'Status', section: 'ueberblick', find: 'uptime laufzeit datenbank backup version zahlen übersicht' }, { id: 'module', Icon: IconLayers, label: 'Module', section: 'ueberblick', find: 'funktionen an aus schalter aktivieren deaktivieren' }, { id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick', find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' }, { id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt', find: 'avatar banner farbe logo name footer aussehen embed' }, { id: 'seiten', Icon: IconPages, label: 'Seiten', section: 'auftritt', find: 'regeln über uns faq markdown menü impressum' }, { id: 'feeds', Icon: IconFeeds, label: 'Feeds', section: 'inhalte', find: 'devlog commit release changelog rückblick threads ping' }, { id: 'composer', Icon: IconComposer, label: 'Composer', section: 'inhalte', find: 'nachricht senden embed bauen tags trigger geplant zeitplan' }, { id: 'community', Icon: IconCommunity, label: 'Community', section: 'community', find: 'level xp starboard galerie geburtstag playtester alpha keys voting wünsche autorolle' }, { id: 'rollen', Icon: IconRoles, label: 'Rollen', section: 'community', find: 'rollen menü selbstbedienung buttons reaction roles' }, { id: 'support', Icon: IconSupport, label: 'Support', section: 'community', find: 'ticket modmail willkommen protokoll modlog moderation warnung' }, { id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen', section: 'community', find: 'formular fragen bewerbung prüfen annehmen ablehnen' }, { id: 'server', Icon: IconServer, label: 'Server', section: 'technik', find: 'game server monitor fivem minecraft status spieler gamedig' }, { id: 'system', Icon: IconSystem, label: 'System', section: 'technik', find: 'backup gitea watchdog twitch youtube events sprachkanäle impressum datenschutz' }, { id: 'api', Icon: IconApi, label: 'API', section: 'zugang', find: 'schlüssel key token zugriff sso webhook' }, { id: 'team', Icon: IconTeam, label: 'Team', section: 'zugang', find: 'mitarbeiter rechte bereiche protokoll audit zugriff' }, ]; // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) const TAB_SCOPES = { status: 'any', module: 'settings', texte: 'settings', brand: null, feeds: 'settings', community: 'community', rollen: 'rollen', support: 'settings', bewerbungen: 'bewerbungen', composer: 'content', seiten: 'content', server: 'server', system: null, api: null, team: null, }; const TEAM_SCOPE_LABELS = { content: 'Composer, Tags, Triggers & geplante Posts', community: 'Playtester & Alpha-Keys', rollen: 'Rollen-Menüs', bewerbungen: 'Bewerbungen', server: 'Game-Server', settings: 'Kanäle & Feed-Einstellungen', devlogs: 'Devlogs löschen', }; /** Sekunden → "2d 4h 13m" */ /** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon. * Filtert die Presets beim Tippen, freie gamedig-IDs bleiben erlaubt. */ function GameSelect({ presets, value, onChange }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); useEffect(() => { function onDocClick(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); } document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, []); const q = value.trim().toLowerCase(); const filtered = presets.filter(([id, label]) => !q || id.includes(q) || label.toLowerCase().includes(q) ); return (
setOpen(true)} onChange={(e) => { onChange(e.target.value); setOpen(true); }} onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }} /> { e.preventDefault(); setOpen((o) => !o); }}>▾ {open && filtered.length > 0 && (
{filtered.map(([id, label]) => ( ))}
)}
); } function fmtUptime(s) { const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' '); } function fmtBytes(b) { if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`; return `${(b / 1024).toFixed(0)} KB`; } export default function Settings({ me }) { // Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten const [tab, setTabState] = useState( () => TABS.find((t) => t.id === window.location.hash.slice(1))?.id ?? 'status' ); const setTab = (id) => { setTabState(id); window.history.replaceState(null, '', `#${id}`); }; const [tabSearch, setTabSearch] = useState(''); const [data, setData] = useState(null); const [form, setForm] = useState(null); const [error, setError] = useState(null); const [feedback, setFeedback] = useState(null); // Community const [playtesters, setPlaytesters] = useState([]); // Rollen-Menüs const emptyMenu = { id: null, title: '', description: '', channel_id: '', exclusive: false, entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }], }; const [menus, setMenus] = useState([]); const [menuDraft, setMenuDraft] = useState(emptyMenu); // Branding const [giteaToken, setGiteaToken] = useState(''); const [botName, setBotName] = useState(''); const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' }); // Alpha-Keys const [alphaKeys, setAlphaKeys] = useState(null); const [keyUpload, setKeyUpload] = useState(''); // Bewerbungs-Formulare const emptyForm = { id: null, title: '', description: '', review_channel_id: '', approve_role_id: '', post_channel_id: '', questions: [''], }; const [appForms, setAppForms] = useState([]); const [formDraft, setFormDraft] = useState(emptyForm); // Triggers const [triggers, setTriggers] = useState([]); const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' }); // Game-Server const GAME_PRESETS = [ ['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'], ['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'], ['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'], ['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'], ['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'], ['garrysmod', '🔧 Garry’s Mod'], ['satisfactory', '🏭 Satisfactory'], ['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'], ['unturned', '🏝️ Unturned'], ]; const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '' }; const [servers, setServers] = useState([]); const [serverDraft, setServerDraft] = useState(emptyServer); // Bot-Beschreibung + Team const [botDesc, setBotDesc] = useState(''); const [team, setTeam] = useState([]); const [audit, setAudit] = useState([]); // Module (An/Aus für alle Funktionen) const [modules, setModules] = useState([]); const [moduleGroups, setModuleGroups] = useState([]); // Text-Vorlagen (was der Bot nach außen schreibt) const [templates, setTemplates] = useState([]); const [templateGroups, setTemplateGroups] = useState([]); const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text // Eigene Seiten (Regeln, Über uns, …) const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const [pages, setPages] = useState([]); const [pageDraft, setPageDraft] = useState(emptyPage); // Portal-Dienste (Kacheln auf der Startseite) const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 }; const [services, setServices] = useState([]); const [serviceDraft, setServiceDraft] = useState(emptyService); // SSO-Apps (Fremd-Dienste mit Discord-Login) const [ssoApps, setSsoApps] = useState([]); const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' }); const [createdSso, setCreatedSso] = useState(null); const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] }); const canTab = (id) => { if (me.admin) return true; const need = TAB_SCOPES[id]; if (need === 'any') return (me.scopes?.length ?? 0) > 0; return need ? me.scopes?.includes(need) : false; }; const visibleTabs = TABS.filter((t) => canTab(t.id)); // Suche greift auf Beschriftung und Stichwörter — wer „geburtstag" tippt, // landet bei Community, ohne den Tab-Namen zu kennen. const query = tabSearch.trim().toLowerCase(); const foundTabs = query ? visibleTabs.filter((t) => `${t.label} ${t.find}`.toLowerCase().includes(query)) : visibleTabs; // Tags + geplante Posts const [tags, setTags] = useState([]); const [tagDraft, setTagDraft] = useState({ name: '', content: '' }); const [scheduled, setScheduled] = useState([]); const [schedDraft, setSchedDraft] = useState({ channel_id: '', embed_title: '', content: '', type: 'once', date: '', time: '18:00', weekday: '5', }); // API-Keys const [keys, setKeys] = useState([]); const [newKeyName, setNewKeyName] = useState(''); const [newKeyScopes, setNewKeyScopes] = useState(['message']); const [createdKey, setCreatedKey] = useState(null); useEffect(() => { if (!me.admin && !(me.scopes?.length > 0)) return; apiGet('/api/settings') .then((d) => { setData(d); setForm(d.settings); }) .catch((e) => setError(e.status)); apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {}); apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {}); apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {}); apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {}); apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {}); apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {}); apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {}); apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {}); apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {}); apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {}); apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); apiGet('/api/modules').then((d) => { setModules(d.modules); setModuleGroups(d.groups); }).catch(() => {}); apiGet('/api/texts').then((d) => { setTemplates(d.templates); setTemplateGroups(d.groups); }).catch(() => {}); }, [me.admin, me.scopes?.length]); useEffect(() => { if (data?.status?.botName) setBotName(data.status.botName); if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription); }, [data]); // Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln useEffect(() => { if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id); }, [me.admin, me.scopes]); async function saveTeam() { if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) { flash('✗ User-ID und mindestens ein Bereich nötig'); return; } try { const res = await apiPut('/api/webadmins', teamDraft); setTeam(res.admins); setTeamDraft({ user_id: '', scopes: [] }); flash('✓ Team-Mitglied gespeichert'); } catch { flash('✗ Fehlgeschlagen — User-ID prüfen'); } } async function removeTeam(userId) { if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return; const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null); if (res) setTeam(res.admins); } async function toggleModule(id, enabled) { // Sofort umschalten, damit sich der Schalter nicht träge anfühlt setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled } : m))); try { const res = await apiPut(`/api/modules/${id}`, { enabled }); setModules(res.modules); } catch { setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled: !enabled } : m))); flash('✗ Umschalten fehlgeschlagen'); } } /** Vorlage speichern. Leerer Text stellt den Standard wieder her. */ async function saveTemplate(id, text) { try { const res = await apiPut(`/api/texts/${encodeURIComponent(id)}`, { text }); setTemplates(res.templates); setTplDraft(({ [id]: _drop, ...rest }) => rest); flash(text.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt'); } catch { flash('✗ Speichern fehlgeschlagen'); } } /** Platzhalter an der Cursor-Position einfügen */ function insertPlaceholder(tpl, key) { const field = document.getElementById(`tpl-${tpl.id}`); const current = tplDraft[tpl.id] ?? tpl.text; const at = field?.selectionStart ?? current.length; const next = `${current.slice(0, at)}{${key}}${current.slice(field?.selectionEnd ?? at)}`; setTplDraft((old) => ({ ...old, [tpl.id]: next })); // Cursor hinter den eingefügten Platzhalter setzen requestAnimationFrame(() => { if (!field) return; field.focus(); const pos = at + key.length + 2; field.setSelectionRange(pos, pos); }); } /** Titel → Adress-Kürzel (Umlaute mit auflösen) */ function slugify(value) { return String(value).toLowerCase() .replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss') .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') .slice(0, 40); } async function savePageDraft() { if (!pageDraft.title.trim() || !pageDraft.slug) { flash('✗ Titel und Kürzel nötig'); return; } try { const res = await apiPut(`/api/pages/${pageDraft.slug}`, pageDraft); setPages(res.pages.map((p) => ({ ...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu), }))); setPageDraft({ ...pageDraft, isNew: false }); flash('✓ Seite gespeichert'); } catch (e) { flash(e.status === 400 ? '✗ Kürzel ist reserviert oder ungültig' : '✗ Speichern fehlgeschlagen'); } } async function removePage(slug) { if (!window.confirm(`Seite „/${slug}" wirklich löschen?`)) return; const res = await apiDelete(`/api/pages/${slug}`).catch(() => null); if (res) { setPages(res.pages.map((p) => ({ ...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu), }))); if (pageDraft.slug === slug) setPageDraft(emptyPage); } } async function saveService() { if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) { flash('✗ Name und URL nötig'); return; } try { const res = serviceDraft.id ? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft) : await apiPost('/api/services', serviceDraft); setServices(res.services); setServiceDraft(emptyService); flash('✓ Dienst gespeichert'); } catch { flash('✗ Speichern fehlgeschlagen (URL prüfen)'); } } async function removeService(id) { const res = await apiDelete(`/api/services/${id}`).catch(() => null); if (res) setServices(res.services); } async function createSsoApp() { if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) { flash('✗ Kürzel, Name und Rücksprung-URL nötig'); return; } try { const res = await apiPost('/api/ssoapps', ssoDraft); setCreatedSso(res); setSsoDraft({ slug: '', name: '', redirect_prefix: '' }); setSsoApps((await apiGet('/api/ssoapps')).apps); flash('✓ App registriert'); } catch (e) { flash(e.status === 409 ? '✗ Kürzel schon vergeben' : '✗ Registrierung fehlgeschlagen'); } } async function removeSsoApp(slug) { if (!window.confirm(`App „${slug}" entfernen? Der Login über diese App funktioniert danach nicht mehr.`)) return; const res = await apiDelete(`/api/ssoapps/${slug}`).catch(() => null); if (res) setSsoApps(res.apps); } const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type); async function saveServer() { if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) { flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig'); return; } try { if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft); else await apiPost('/api/gameservers', serverDraft); setServers((await apiGet('/api/gameservers')).servers); setServerDraft(emptyServer); flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick'); } catch { flash('✗ Speichern fehlgeschlagen (URL mit http/https?)'); } } async function removeServer(id) { if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return; await apiDelete(`/api/gameservers/${id}`).catch(() => {}); setServers((await apiGet('/api/gameservers')).servers); if (serverDraft.id === id) setServerDraft(emptyServer); } async function uploadAlphaKeys() { if (!keyUpload.trim()) return; try { const res = await apiPost('/api/alphakeys', { keys: keyUpload }); setAlphaKeys(res); setKeyUpload(''); flash(`✓ ${res.added} Keys hinzugefügt`); } catch { flash('✗ Upload fehlgeschlagen'); } } async function distributeKeys() { if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return; try { const res = await apiPost('/api/alphakeys/distribute'); setAlphaKeys(res); flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`); } catch { flash('✗ Verteilung fehlgeschlagen'); } } async function saveForm(publish = false) { const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean); if (!formDraft.title.trim() || questions.length === 0) { flash('✗ Titel und mindestens eine Frage nötig'); return; } try { const body = { ...formDraft, questions }; const res = formDraft.id ? await apiPut(`/api/appforms/${formDraft.id}`, body) : await apiPost('/api/appforms', body); if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`); setAppForms((await apiGet('/api/appforms')).forms); setFormDraft(emptyForm); flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert'); } catch { flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen'); } } async function removeForm(id) { if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return; await apiDelete(`/api/appforms/${id}`).catch(() => {}); setAppForms((await apiGet('/api/appforms')).forms); if (formDraft.id === id) setFormDraft(emptyForm); } async function saveTriggerDraft() { if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) { flash('✗ Keyword (min 3 Zeichen) und Antwort nötig'); return; } try { await apiPut('/api/triggers', triggerDraft); setTriggers((await apiGet('/api/triggers')).triggers); setTriggerDraft({ keyword: '', reply: '' }); flash('✓ Trigger gespeichert'); } catch { flash('✗ Trigger fehlgeschlagen'); } } async function removeTrigger(keyword) { await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {}); setTriggers((await apiGet('/api/triggers')).triggers); } async function uploadImage(target, file) { if (!file) return; const reader = new FileReader(); reader.onload = async () => { try { await apiPost(`/api/branding/${target}`, { data: reader.result }); flash(`✓ ${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`); } catch { flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)'); } }; reader.readAsDataURL(file); } async function saveBrand() { try { const body = { ...form, bot_name: botName, bot_description: botDesc }; if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim(); if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim(); if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim(); const res = await apiPut('/api/settings', body); setForm(res.settings); setGiteaToken(''); setTwitchCreds({ id: '', secret: '' }); flash('✓ Gespeichert & angewendet'); } catch { flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)'); } } async function saveTagDraft() { if (!tagDraft.name.trim() || !tagDraft.content.trim()) { flash('✗ Name und Inhalt nötig'); return; } try { await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content }); setTags((await apiGet('/api/tags')).tags); setTagDraft({ name: '', content: '' }); flash('✓ Tag gespeichert'); } catch { flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)'); } } async function removeTag(name) { if (!window.confirm(`Tag „${name}" löschen?`)) return; await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {}); setTags((await apiGet('/api/tags')).tags); } async function createSchedule() { if (!schedDraft.channel_id || !schedDraft.content.trim()) { flash('✗ Kanal und Text nötig'); return; } try { await apiPost('/api/scheduled', schedDraft); setScheduled((await apiGet('/api/scheduled')).posts); setSchedDraft({ ...schedDraft, content: '', embed_title: '' }); flash('✓ Post geplant'); } catch { flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen'); } } async function removeSchedule(id) { await apiDelete(`/api/scheduled/${id}`).catch(() => {}); setScheduled((await apiGet('/api/scheduled')).posts); } function flash(msg) { setFeedback(msg); setTimeout(() => setFeedback(null), 3000); } async function save() { try { const body = { ...form }; if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim(); if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim(); const res = await apiPut('/api/settings', body); setForm(res.settings); setTwitchCreds({ id: '', secret: '' }); flash('✓ Gespeichert'); } catch { flash('✗ Speichern fehlgeschlagen'); } } async function sendTest(target) { try { await apiPost(`/api/settings/test/${target}`); flash('✓ Test-Nachricht gesendet'); } catch { flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen'); } } async function createKey() { if (!newKeyName.trim() || newKeyScopes.length === 0) return; try { const res = await apiPost('/api/apikeys', { name: newKeyName.trim(), scopes: newKeyScopes }); setCreatedKey(res); setNewKeyName(''); setKeys((await apiGet('/api/apikeys')).keys); } catch { flash('✗ Key-Erstellung fehlgeschlagen'); } } async function revokeKey(id) { if (!window.confirm('Diesen API-Key widerrufen? Skripte mit diesem Key verlieren sofort den Zugriff.')) return; await apiDelete(`/api/apikeys/${id}`).catch(() => {}); setKeys((await apiGet('/api/apikeys')).keys); if (createdKey?.id === id) setCreatedKey(null); } const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus); async function saveMenu(publish = false) { const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id); if (!menuDraft.title.trim() || entries.length === 0) { flash('✗ Titel und mindestens eine Rolle nötig'); return; } if (publish && !menuDraft.channel_id) { flash('✗ Zum Posten einen Kanal wählen'); return; } try { const body = { ...menuDraft, entries }; const res = menuDraft.id ? await apiPut(`/api/rolemenus/${menuDraft.id}`, body) : await apiPost('/api/rolemenus', body); let menu = res.menu; if (publish) { await apiPost(`/api/rolemenus/${menu.id}/publish`); flash('✓ Menü in Discord gepostet'); } else { flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert'); } await reloadMenus(); setMenuDraft(emptyMenu); } catch { flash('✗ Speichern fehlgeschlagen — Einträge prüfen'); } } async function removeMenu(id) { if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return; await apiDelete(`/api/rolemenus/${id}`).catch(() => {}); if (menuDraft.id === id) setMenuDraft(emptyMenu); await reloadMenus(); } const editMenu = (m) => setMenuDraft({ id: m.id, title: m.title, description: m.description, channel_id: m.channel_id ?? '', exclusive: m.exclusive, entries: m.entries.length ? m.entries.map((e) => ({ style: 'secondary', ...e })) : [{ emoji: '', label: '', role_id: '', style: 'secondary' }], }); const setMenuEntry = (i, patch) => setMenuDraft({ ...menuDraft, entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)), }); const header = (
CONFIG
); if (me.loading) return <>{header}

Lade …

; if (!me.user || (!me.admin && !(me.scopes?.length > 0))) { return ( <> {header}

Nur für den Admin.

{!me.user && Login mit Discord}
); } if (error) return <>{header}

Fehler beim Laden ({error}).

; if (!data || !form) return <>{header}

Lade …

; const channelOptions = data.channels.map((c) => ( )); const roleOptions = (data.roles ?? []).map((r) => ( )); const channelName = (id) => { const c = data.channels.find((x) => x.id === id); return c ? `#${c.name}` : id; }; /* ── Tab-Inhalte ───────────────────────────────── */ // Eingeschaltete Module, denen noch etwas fehlt — die tun bis dahin nichts const activeModules = modules.filter((m) => m.enabled); const unfinished = activeModules.filter((m) => m.missing.length > 0); const tabStatus = ( <>

// Status

{data.status.botTag ?? '—'}Bot
{fmtUptime(data.status.uptimeSeconds)}Uptime
{modules.length > 0 && (
{activeModules.length}/{modules.length} Module an
)}
{data.status.devlogs}Devlogs
{data.status.commits}Commits
{fmtBytes(data.status.dbSizeBytes)}Datenbank
{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'} Letztes Backup
{modules.length > 0 && (

// Noch einzurichten

{unfinished.length === 0 ? (

Alles eingerichtet — jedes eingeschaltete Modul hat, was es braucht.

) : ( <>

Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas — bis dahin tun sie nichts.

{unfinished.map((m) => (
{m.name} {m.missing.join(', ')} {m.tab && ( )}
))}
)}
)} ); const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a'; const tabBrand = ( <>
{data.status.botAvatar ? Bot-Avatar :
}
setBotName(e.target.value)} />