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, IconSystem as IconTune, IconStar, IconGame, IconTrash, } from '../icons.jsx'; import { moduleIcon } from '../module-icons.jsx'; import ModulePage from '../components/ModulePage.jsx'; import UptimeTrack from '../components/UptimeTrack.jsx'; import Dialog from '../components/Dialog.jsx'; import EmojiPick from '../components/EmojiPick.jsx'; import { findSettings, SETTING_INDEX } from '../setting-index.js'; 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: 'werte', Icon: IconTune, label: 'Werte', section: 'ueberblick', find: 'xp cooldown wartezeit intervall prüfung timeout obergrenze zahlen uhrzeit' }, { 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: 'playtester', Icon: IconGame, label: 'Playtester', section: 'community', find: 'tester alpha keys schlüssel vergeben pool testen einladen' }, { 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' }, ]; // Stand eines Wunsches. Reihenfolge = Weg durchs Verfahren, damit die Knöpfe // in der Ansicht so nebeneinander liegen, wie man sie durchläuft. const WUNSCH_STATUS = [ ['offen', 'Wird geprüft'], ['geplant', 'Geplant'], ['in_arbeit', 'In Arbeit'], ['umgesetzt', 'Umgesetzt'], ['abgelehnt', 'Nicht geplant'], ]; // Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen. // Alles mit Editor, Vorschau oder Liste bleibt einspaltig. const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']); // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) const TAB_SCOPES = { status: 'any', module: 'settings', texte: 'settings', werte: 'settings', brand: null, feeds: 'settings', community: 'community', rollen: 'rollen', support: 'settings', playtester: 'community', bewerbungen: 'bewerbungen', composer: 'content', seiten: 'content', server: 'server', system: null, api: null, team: null, }; /** „vor 3 Minuten" statt rohem Zeitstempel */ /** * Zeitstempel aus der Datenbank lesen — es kommen zwei Formate an: * * "2026-07-30 12:34:56" SQLite datetime('now'), UTC ohne Kennzeichnung * "2026-07-30T12:34:56.789Z" toISOString(), Zone schon dabei * * Beim ersten muss das Z ergänzt werden, sonst rechnet der Browser die Zeit * als Ortszeit. Beim zweiten darf es das gerade nicht — "…ZZ" ergibt ein * ungültiges Datum und in der Anzeige stand dann NaN. */ function alsDatum(wert) { if (!wert) return null; const s = String(wert).trim(); const hatZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(s); const d = new Date(hatZone ? s : `${s.replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d; } function vorWieLange(wert) { const d = alsDatum(wert); if (!d) return '—'; const min = Math.floor((Date.now() - d.getTime()) / 60000); if (min < 1) return 'gerade eben'; if (min < 60) return `vor ${min} Min`; const std = Math.floor(min / 60); if (std < 24) return `vor ${std} Std`; const tage = Math.floor(std / 24); return tage === 1 ? 'gestern' : `vor ${tage} Tagen`; } /** Avatar mit Anfangsbuchstaben als Rückfallebene. * Bewusst außerhalb der Komponente: als Inline-Komponente wäre der Typ bei * jedem Rendern neu und React würde die Bilder jedes Mal neu einhängen. */ const Avatar = ({ src, name, klein }) => (src ? : {(name ?? '?').slice(0, 1).toUpperCase()}); 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" */ // Was ohne Suche oben steht. Nur eine Startauswahl — gesucht wird über alle // Spiele, die gamedig kennt. Die IDs hier sind echte gamedig-IDs; wer eine // ergänzt, prüft sie gegen /api/gamedig-games und nicht gegen sein Gedächtnis. const BELIEBTE_SPIELE = [ 'fivem', 'http', 'minecraft', 'mbe', 'rust', 'valheim', 'palworld', 'farmingsimulator25', 'projectzomboid', 'sdtd', 'ase', 'garrysmod', 'counterstrike2', 'arma3', 'satisfactory', 'unturned', ]; const GAMESEL_MAX = 60; /** * Ganze Abfrage-URL eingefügt statt Host getippt? Dann Host, Port und Code * daraus ziehen. * * Bei Farming Simulator ist genau das der Normalfall: der Hoster gibt einem * eine fertige URL wie * `http://1.2.3.4:8200/feed/dedicated-server-stats.xml?code=…`, * und die drei Felder von Hand auseinanderzupflücken ist Arbeit, die niemand * machen muss. Der Port steht mit drin — er ist nicht immer 8080. * * @returns {{host: string, port: string, token?: string} | null} */ function feedUrlZerlegen(text) { const roh = String(text).trim(); if (!/^https?:\/\//i.test(roh)) return null; let u; try { u = new URL(roh); } catch { return null; } if (!u.hostname) return null; const code = u.searchParams.get('code') || u.searchParams.get('token') || ''; return { host: u.hostname, port: u.port || '', // Ohne Code im Link den vorhandenen stehen lassen, statt ihn zu leeren ...(code ? { token: code } : {}), }; } /** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon. * Die Liste kommt aus gamedig selbst, damit sie mit jedem Update mitwächst; * freie IDs bleiben trotzdem erlaubt. */ function GameSelect({ games, 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(); // Ohne Eingabe die Startauswahl, sonst alles durchsuchen. 360 Einträge auf // einmal wären keine Auswahl mehr, sondern eine Wand. const treffer = q ? games.filter((g) => g.id.includes(q) || g.name.toLowerCase().includes(q)) : BELIEBTE_SPIELE.map((id) => games.find((g) => g.id === id)).filter(Boolean); const gezeigt = treffer.slice(0, GAMESEL_MAX); const rest = treffer.length - gezeigt.length; 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 && gezeigt.length > 0 && (
{gezeigt.map((g) => ( ))} {rest > 0 &&
… und {rest} weitere — weitertippen
}
)}
); } // Nur Deko in der Auswahlliste; die Seite holt sich ihr Icon aus der API. const GAME_EMOJI = { fivem: '🚗', http: '🌐', minecraft: '⛏️', mbe: '⛏️', rust: '🪓', valheim: '🛡️', palworld: '🐑', farmingsimulator19: '🚜', farmingsimulator22: '🚜', farmingsimulator25: '🚜', projectzomboid: '🧟', sdtd: '🧟', dayz: '🧟', ase: '🦖', asa: '🦖', garrysmod: '🔧', counterstrike2: '🔫', csgo: '🔫', arma3: '🪖', squad: '🪖', satisfactory: '🏭', unturned: '🏝️', terrariatshock: '🌳', teamfortress2: '🎩', }; /** * Durchsuchbare Auswahl für Kanäle und Rollen — dieselbe Bedienung wie bei den * gamedig-IDs. Ein natives { setOpen(true); setQuery(''); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setQuery(''); e.target.blur(); } if (e.key === 'Enter' && filtered[0]) { onChange(filtered[0].id); setOpen(false); setQuery(''); e.target.blur(); } }} /> { e.preventDefault(); setOpen((o) => !o); }}> {open && (
{filtered.map((i) => ( ))} {filtered.length === 0 && Nichts gefunden}
)} ); } 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).split('/')[0])?.id ?? 'status' ); const setTab = (id) => { setTabState(id); setOpenModuleState(null); 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 // 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); // Offene Dialoge: Schlüssel nachlegen bzw. jemanden auswählen const [keyDialog, setKeyDialog] = useState(false); const [keyEmpfaenger, setKeyEmpfaenger] = useState(null); const [keyUpload, setKeyUpload] = useState(''); // Bewerbungs-Formulare const emptyForm = { id: null, title: '', description: '', review_channel_id: '', approve_role_id: '', post_channel_id: '', questions: [''], playtester: false, }; const [appForms, setAppForms] = useState([]); const [formDraft, setFormDraft] = useState(emptyForm); // Triggers const [triggers, setTriggers] = useState([]); const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' }); // Game-Server — die Spielliste kommt aus gamedig (siehe /api/gamedig-games) const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '', token: '' }; const [servers, setServers] = useState([]); const [games, setGames] = useState([]); const [serverDraft, setServerDraft] = useState(emptyServer); // Bot-Beschreibung + Team const [botDesc, setBotDesc] = useState(''); const [team, setTeam] = useState([]); const [audit, setAudit] = useState([]); const [serverStats, setServerStats] = useState(null); const [watchdog, setWatchdog] = useState(null); const [automod, setAutomod] = useState(null); const [anliegen, setAnliegen] = useState([]); const [wuensche, setWuensche] = useState([]); const [statusDialog, setStatusDialog] = useState(null); const [statusGrund, setStatusGrund] = useState(''); const [mergeVon, setMergeVon] = useState(null); const [bereiche, setBereiche] = useState([]); const [bereichDraft, setBereichDraft] = useState({ id: null, name: '', emoji: '' }); const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' }); const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false }); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); // Module (An/Aus für alle Funktionen) const [modules, setModules] = useState([]); const [moduleGroups, setModuleGroups] = useState([]); const [modFilter, setModFilter] = useState('alle'); // Offene Modul-Seite steht mit in der Adresse: #module/welcome const [openModule, setOpenModuleState] = useState( () => window.location.hash.split('/')[1] ?? null ); const setOpenModule = (id) => { setOpenModuleState(id); window.history.replaceState(null, '', id ? `#module/${id}` : '#module'); }; const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird // Funktionen, die in der Seitenleiste stehen const [favorites, setFavorites] = useState([]); const [maxFavorites, setMaxFavorites] = useState(8); // Grundgerüst-Assistent const [plan, setPlan] = useState(null); // Vorschau, null = zu const [setupRunning, setSetupRunning] = useState(false); const [setupResult, setSetupResult] = useState(null); // Text-Vorlagen (was der Bot nach außen schreibt) const [templates, setTemplates] = useState([]); const [templateGroups, setTemplateGroups] = useState([]); const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage // Stellwerte (XP, Wartezeiten, Intervalle, Obergrenzen) const [tuningValues, setTuningValues] = useState([]); const [tuningGroups, setTuningGroups] = useState([]); const [tuneDraft, setTuneDraft] = useState({}); // id → eingetippter Wert // 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; // … und zusätzlich auf einzelne Einstellungen, mit Sprung aufs Feld const foundSettings = query ? findSettings(query, canTab) : []; /** * Zum Feld einer Einstellung springen — der Bereich kommt aus dem Register, * damit „Einrichten" nicht im falschen Tab landet (das Gitea-Token steht * z. B. bei Brand, nicht bei System). */ function jumpToMissing(fehlt, fallbackTab) { const bekannt = SETTING_INDEX.find((s) => s.id === fehlt.setting); jumpToSetting(bekannt ?? { id: fehlt.setting, tab: fallbackTab }); } /** Bereich wechseln, zum Feld scrollen und es kurz hervorheben */ function jumpToSetting(entry) { setTab(entry.tab); setTabSearch(''); const key = entry.target ?? entry.id; // Zwei Bilder warten, bis der neue Bereich wirklich im Dokument steht requestAnimationFrame(() => requestAnimationFrame(() => { const el = document.querySelector(`[data-setting="${key}"]`); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('field-found'); setTimeout(() => el.classList.remove('field-found'), 2200); // Nur Textfelder fokussieren: bei der Kanal-Auswahl würde der Fokus // die Liste aufklappen und alles darunter verdecken. const feld = el.querySelector('input, textarea'); if (feld && !feld.closest('.picksel')) feld.focus({ preventScroll: true }); })); } // 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/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/gamedig-games').then((d) => setGames(d.games ?? [])).catch(() => {}); apiGet('/api/webadmins') .then((d) => { setTeam(d.admins); setOwner(d.owner); }) .catch(() => {}); apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {}); // Fuers Dashboard: Mitgliederzahl und Nachrichten pro Tag apiGet('/api/serverstats').then(setServerStats).catch(() => {}); apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); apiGet('/api/automod').then(setAutomod).catch(() => {}); apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {}); apiGet('/api/wishes').then((d) => { setWuensche(d.wishes ?? []); setBereiche(d.bereiche ?? []); }).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); setFavorites(d.favorites ?? []); setMaxFavorites(d.maxFavorites ?? 8); }).catch(() => {}); apiGet('/api/texts').then((d) => { setTemplates(d.templates); setTemplateGroups(d.groups); }).catch(() => {}); apiGet('/api/tuning').then((d) => { setTuningValues(d.values); setTuningGroups(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]); // Vorschau der Willkommens-Karte. Verzögert, damit nicht jeder Tastendruck // ein Rendering auslöst — die Karte wird serverseitig mit sharp gebaut. const [welcomePreviewUrl, setWelcomePreviewUrl] = useState('/api/welcome-preview.png'); useEffect(() => { if (!form) return undefined; const timer = setTimeout(() => { const q = new URLSearchParams({ background: form.welcome_card_bg ?? '', accent: form.welcome_card_accent ?? '', accent2: form.welcome_card_accent2 ?? '', text: form.welcome_card_text ?? '', kicker: form.welcome_card_kicker ?? '', sub: form.welcome_card_sub ?? '', image: form.welcome_card_image ?? '', watermark: form.welcome_card_watermark ? '1' : '0', }); setWelcomePreviewUrl(`/api/welcome-preview.png?${q}`); }, 450); return () => clearTimeout(timer); }, [ form?.welcome_card_bg, form?.welcome_card_accent, form?.welcome_card_accent2, form?.welcome_card_text, form?.welcome_card_kicker, form?.welcome_card_sub, form?.welcome_card_image, form?.welcome_card_watermark, ]); // Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln useEffect(() => { const onHash = () => { const id = TABS.find((t) => t.id === window.location.hash.slice(1).split('/')[0])?.id; if (id) setTabState(id); }; window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); // 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'); } } /** Funktion in der Seitenleiste an- oder abheften */ async function toggleFavorite(id) { const dabei = favorites.includes(id); const neu = dabei ? favorites.filter((f) => f !== id) : [...favorites, id]; const vorher = favorites; setFavorites(neu); // sofort umschalten try { const res = await apiPut('/api/module-favorites', { ids: neu }); setFavorites(res.favorites); } catch (error) { setFavorites(vorher); // bei Ablehnung zurück flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`); } } /** Vorschau holen: was würde der Assistent anlegen? */ async function loadPlan() { try { const res = await apiGet('/api/setup/plan'); setPlan(res); } catch (error) { flash(`✗ ${error.body?.error ?? 'Vorschau fehlgeschlagen'}`); } } /** Grundgerüst anlegen — erst nach der Vorschau erreichbar */ async function runSetup() { setSetupRunning(true); try { const res = await apiPost('/api/setup/run', { kategorie: plan.kategorie }); setModules(res.modules); await reloadSettings(); setSetupResult(res.ergebnisse); setPlan(null); const neu = res.ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length; flash(`✓ ${neu} neu angelegt`); } catch (error) { flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`); } finally { setSetupRunning(false); } } /** * Kanäle, Rollen und Einstellungen neu holen. Nötig nach dem Anlegen: der * frische Kanal steht sonst zwar in der Einstellung, fehlt aber in der * Auswahlliste — das Feld sähe leer aus, obwohl es gesetzt ist. */ async function reloadSettings() { const frisch = await apiGet('/api/settings').catch(() => null); if (!frisch) return; setData(frisch); setForm(frisch.settings); } /** Fehlenden Kanal / fehlende Rolle vom Bot anlegen lassen */ async function createMissing(entry) { setCreating(entry.setting); try { const res = await apiPost('/api/setup/create', { setting: entry.setting }); setModules(res.modules); await reloadSettings(); flash(res.wiederverwendet ? `✓ Vorhandenen ${res.art === 'rolle' ? 'Rolle' : 'Kanal'} „${res.name}" verknüpft` : `✓ ${res.art === 'rolle' ? 'Rolle' : 'Kanal'} „${res.name}" angelegt`); } catch (error) { flash(`✗ ${error.body?.error ?? 'Anlegen fehlgeschlagen'}`); } finally { setCreating(null); } } /** Alle geänderten Stellwerte auf einmal speichern */ async function saveTuning() { const changed = Object.fromEntries( Object.entries(tuneDraft).filter(([id, v]) => { const current = tuningValues.find((t) => t.id === id); return current && String(v) !== String(current.value); }) ); if (Object.keys(changed).length === 0) return; try { const res = await apiPut('/api/tuning', changed); setTuningValues(res.values); setTuneDraft({}); flash('✓ Werte gespeichert'); } catch (error) { flash(`✗ ${error.body?.error ?? 'Speichern fehlgeschlagen'}`); } } /** Einen Wert auf den Standard zurücksetzen */ async function resetTuning(id) { try { const res = await apiPut('/api/tuning', { [id]: '' }); setTuningValues(res.values); setTuneDraft(({ [id]: _drop, ...rest }) => rest); flash('✓ Auf den Standard zurückgesetzt'); } catch { flash('✗ Zurücksetzen 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); const gewaehltesSpiel = games.find((g) => g.id === serverDraft.type) ?? null; /** Ins Host-Feld getippt — oder die ganze Abfrage-URL eingefügt */ function hostEingeben(wert) { const teile = feedUrlZerlegen(wert); setServerDraft((d) => (teile ? { ...d, ...teile } : { ...d, host: wert })); } /** Spiel gewählt: Standard-Port vorschlagen, solange keiner drinsteht */ function spielWaehlen(type) { const spiel = games.find((g) => g.id === type); setServerDraft((d) => ({ ...d, type, port: d.port || (spiel && !spiel.eigen ? String(spiel.port ?? '') : d.port), })); } 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; } // Ohne Code antwortet so ein Server gar nicht — er stünde dauerhaft auf // "offline", ohne dass irgendwo der Grund stünde. Lieber hier stoppen. if (gewaehltesSpiel?.token === 'pflicht' && !serverDraft.token.trim()) { flash(`✗ ${gewaehltesSpiel.name} antwortet nur mit Zugangs-Code`); 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'); } } /** Einen Schlüssel an eine ausgewählte Person schicken */ async function keyGeben(userId, name) { try { const res = await apiPost('/api/alphakeys/assign', { user_id: userId }); setAlphaKeys(res); setKeyEmpfaenger(null); flash(`✓ Schlüssel an ${name} verschickt`); } catch (e) { flash(`✗ ${e.body?.error ?? 'Vergeben fehlgeschlagen'}`); } } /** Vergebenen Schlüssel zurück in den Vorrat */ async function keyZurueck(key) { if (!window.confirm(`Schlüssel ${key} wieder freigeben? Die Person behält die Nachricht mit dem Schlüssel.`)) return; try { setAlphaKeys(await apiPost('/api/alphakeys/revoke', { key })); } catch { flash('✗ Zurückziehen fehlgeschlagen'); } } /** Freien Schlüssel löschen — z. B. einen vertippten */ async function keyLoeschen(key) { try { setAlphaKeys(await apiDelete(`/api/alphakeys/${encodeURIComponent(key)}`)); } catch (e) { flash(`✗ ${e.body?.error ?? 'Löschen fehlgeschlagen'}`); } } async function playtesterEntfernen(userId, name) { if (!window.confirm(`${name} aus dem Playtester-Programm nehmen? Die Rolle wird mit entfernt.`)) return; try { const res = await apiDelete(`/api/playtesters/${userId}`); setAlphaKeys(await apiGet('/api/alphakeys')); flash(res.rolleWeg ? `✓ ${name} entfernt` : `✓ ${name} entfernt — Rolle blieb (Rechte prüfen)`); } catch { flash('✗ Entfernen 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); // Vergleichsstand mitziehen, sonst meldet die Leiste weiter // „Änderungen offen", obwohl gerade gespeichert wurde setData((d) => (d ? { ...d, settings: res.settings } : d)); 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); } // Welche Einstellungen weichen vom gespeicherten Stand ab? Der Brand-Tab // pflegt Bot-Name und -Beschreibung außerhalb von `form` — die zählen mit, // sonst meldet die Leiste „nichts geändert", während doch etwas offen ist. const changedKeys = data?.settings && form ? Object.keys(form).filter((k) => String(form[k] ?? '') !== String(data.settings[k] ?? '')) : []; const brandChanged = Boolean(data) && ( botName !== (data.status.botName ?? '') || botDesc !== (data.status.botDescription ?? '') || giteaToken.trim() !== '' || twitchCreds.id.trim() !== '' || twitchCreds.secret.trim() !== '' ); const dirtyCount = changedKeys.length + (brandChanged ? 1 : 0); /** Alles zurück auf den gespeicherten Stand */ function discardChanges() { setForm(data.settings); setBotName(data.status.botName ?? ''); setBotDesc(data.status.botDescription ?? ''); setGiteaToken(''); setTwitchCreds({ id: '', secret: '' }); flash('Änderungen verworfen'); } // Beim Verlassen mit offenen Änderungen nachfragen — vorher gingen sie // kommentarlos verloren. useEffect(() => { if (dirtyCount === 0) return undefined; const warn = (e) => { e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, [dirtyCount]); /** Speichern aus der Leiste: saveBrand schickt das Formular mit und deckt * damit beide Fälle ab, wenn auch Brand-Felder offen sind. */ const saveAll = () => (brandChanged ? saveBrand() : save()); 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); // Vergleichsstand mitziehen, sonst meldet die Leiste weiter // „Änderungen offen", obwohl gerade gespeichert wurde setData((d) => (d ? { ...d, settings: res.settings } : d)); 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 …

; // Foren stehen mit dabei — sichtbar gekennzeichnet, weil dort nur der // Voting-Kanal sinnvoll ist: posten kann der Bot in ein Forum nicht const channelItems = data.channels.map((c) => ({ id: c.id, label: `${c.forum ? '📋' : '#'}${c.name}`, hint: c.forum ? 'Forum' : c.guild, })); const roleItems = (data.roles ?? []).map((r) => ({ id: r.id, label: `@${r.name}`, hint: r.guild })); // Der Composer bringt seine eigene Auswahl mit und erwartet )); 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); /** Wie alt ist ein Zeitstempel, in Tagen — fuer die Nebenzeilen */ function alterInTagen(wert) { const d = alsDatum(wert); if (!d) return null; return Math.floor((Date.now() - d.getTime()) / 86400000); } /** „vor 3 Tagen" bzw. „heute" fuer eine Nebenzeile */ function seit(iso, nie) { const t = alterInTagen(iso); if (t == null) return nie; if (t <= 0) return 'heute'; return t === 1 ? 'gestern' : `vor ${t} Tagen`; } // Mitglieder-Zuwachs der letzten 7 Tage aus den Tagesdaten const memberDelta = serverStats?.days ? serverStats.days.slice(-7).reduce((n, d) => n + (d.joins ?? 0) - (d.leaves ?? 0), 0) : null; // Nachrichten der letzten 30 Tage, luckenlos — fehlende Tage sind 0 und // nicht einfach weg, sonst staucht sich der Verlauf const aktivitaet = (() => { if (!serverStats?.days) return null; const proTag = new Map(serverStats.days.map((d) => [d.day, d.messages ?? 0])); const tage = []; for (let i = 29; i >= 0; i--) { const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); tage.push({ key, n: proTag.get(key) ?? 0 }); } return { tage, gesamt: tage.reduce((n, t) => n + t.n, 0), max: Math.max(1, ...tage.map((t) => t.n)) }; })(); const backupTage = alterInTagen(data.status.lastBackup); const tabStatus = ( <>
{data.status.botAvatar ? : ?}
{data.status.botName ?? 'Bot'} {data.status.botTag && {data.status.botTag}} online
seit {fmtUptime(data.status.uptimeSeconds)} {data.status.ping != null && ` · ${data.status.ping} ms`} {data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
{/* Die Laufzeit oben zählt nur bis zum letzten Neustart. Der Balken zeigt die Woche davor — inklusive der Zeiten, in denen gar nichts lief. */} {data.status.uptime?.length > 0 ? (
) : (

Erreichbarkeit wird ab jetzt aufgezeichnet — der Balken füllt sich in den nächsten Stunden.

)}
{activeModules.length}/{modules.length || '–'} Module an 0 ? ' warn' : ' gut'}`}> {unfinished.length > 0 ? `${unfinished.length} unvollständig` : 'alle einsatzbereit'}
{serverStats?.members != null ? serverStats.members.toLocaleString('de-DE') : '—'} Member 0 ? ' gut' : ''}`}> {memberDelta == null ? ' ' : `${memberDelta >= 0 ? '+' : ''}${memberDelta} in 7 Tagen`}
{data.status.devlogs} Devlogs {seit(data.status.lastDevlog, 'noch keins')}
{fmtBytes(data.status.dbSizeBytes)} Datenbank 2 ? ' warn' : ' gut'}`}> Backup {seit(data.status.lastBackup, 'fehlt')}
{aktivitaet && aktivitaet.gesamt > 0 && (

// Aktivität — {aktivitaet.gesamt.toLocaleString('de-DE')} Nachrichten in 30 Tagen

{aktivitaet.tage.map((t) => ( ))}
)} {modules.length > 0 && moduleGroups.length > 0 && (

// Module nach Bereich

{moduleGroups.map((g) => { const drin = modules.filter((m) => m.group === g.id); if (drin.length === 0) return null; const an = drin.filter((m) => m.enabled).length; const offen = drin.filter((m) => m.enabled && m.missing.length > 0).length; return ( ); })}
)} {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. Einzeln anlegen, oder alles auf einmal.

{/* Grundgerüst: erst Vorschau, dann anlegen */} {!plan && (
)} {plan && (

{plan.schritte.filter((s) => !s.vorhanden).length} werden neu angelegt {plan.schritte.some((s) => s.vorhanden) && ` · ${plan.schritte.filter((s) => s.vorhanden).length} sind schon da und werden nur verknüpft`} . Neue Kanäle landen in der Kategorie {plan.kategorie}.

{plan.schritte.map((s) => (
{s.art === 'rolle' ? '@' : '#'}{s.name} {s.vorhanden ? 'vorhanden — wird verknüpft' : s.privat ? 'neu, privat' : 'neu'} {s.label}
))}
)} {setupResult && (

Ergebnis:

{setupResult.map((r) => (
{r.name ?? r.label} {r.ok ? (r.wiederverwendet ? 'verknüpft' : 'angelegt') : r.fehler}
))}
)}
{unfinished.map((m) => (
{m.name} {m.missing.map((x) => x.label).join(', ')} {/* Was der Bot selbst anlegen kann, bekommt einen Knopf dafür */} {m.missing.filter((x) => x.create).map((x) => ( ))}
))}
)}
)} ); const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a'; // Willkommens-Karte: eigener Block, damit sie im Brand-Bereich stehen // kann, wo das uebrige Aussehen gepflegt wird — inhaltlich unveraendert. const willkommensKarte = ( <>

// Willkommen

setForm({ ...form, welcome_channel_id: v })} empty="— deaktiviert —" />

// Willkommens-Karte

Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.

{form.welcome_card_enabled && ( <> Vorschau der Willkommens-Karte
{[ ['welcome_card_bg', 'Hintergrund'], ['welcome_card_accent', 'Akzent 1'], ['welcome_card_accent2', 'Akzent 2'], ['welcome_card_text', 'Name'], ].map(([key, label]) => ( ))}
setForm({ ...form, welcome_card_kicker: e.target.value })} />
setForm({ ...form, welcome_card_sub: e.target.value })} />
setForm({ ...form, welcome_card_image: e.target.value })} />
)}
); const tabBrand = ( <>
{data.status.botAvatar ? Bot-Avatar :
}
setBotName(e.target.value)} />