Files
d4rkbot/frontend/src/pages/Settings.jsx
T
D4rkst3randClaude Opus 5 3080f52e71
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
AutoMod sichtbar machen: Treffer ins Protokoll, Regeln ins Panel
Discords eigener Filter arbeitet serverseitig und ohne Latenz — daran ist
nichts zu verbessern. Was fehlte, war die Sicht darauf: Treffer landeten nur
in Discords eigenem Kanal, und die Regeln pflegte man in einer ganz anderen
Oberflaeche als alles andere.

Jetzt spiegelt der Bot jeden Treffer als Embed ins Mod-Log — mit Nutzer,
Regel, Ausloeser, Massnahme, getroffenem Wort und gekuerztem Wortlaut, ohne
Erwaehnungen, die den Kanal anpingen. Im Panel steht unter Support jede Regel
mit Ausloeser, Massnahmen und der Zahl ausgenommener Rollen und Kanaele —
letzteres oft der Grund, warum eine Regel scheinbar nicht greift. Schalten
geht dort direkt.

Das Bearbeiten der Wortlisten bleibt bewusst in Discord; dort ist es gut
geloest, und ein zweiter Ort dafuer waere nur eine weitere Stelle, an der
etwas auseinanderlaeuft.

Dazu noetig: der Gateway-Intent AutoModerationExecution. Nicht privilegiert,
aber Discord schickt die Ereignisse nur an Bots mit "Server verwalten" —
fehlt das Recht, sagt das Panel es statt still leer zu bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 06:53:33 +02:00

3404 lines
180 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} from '../icons.jsx';
import { moduleIcon } from '../module-icons.jsx';
import ModulePage from '../components/ModulePage.jsx';
import UptimeTrack from '../components/UptimeTrack.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: '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' },
];
// 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',
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
? <img className={`tm-avatar${klein ? ' klein' : ''}`} src={src} alt="" loading="lazy" />
: <span className={`tm-avatar${klein ? ' klein' : ''} ohne`}>{(name ?? '?').slice(0, 1).toUpperCase()}</span>);
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 (
<div className="gamesel" ref={wrapRef} style={{ flex: 'none', width: '13rem' }}>
<input
type="text" placeholder="Spiel (gamedig-ID)"
title="Vorschläge oder beliebige gamedig-Spiel-ID"
value={value}
onFocus={() => setOpen(true)}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }}
/>
<span className="gamesel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}></span>
{open && filtered.length > 0 && (
<div className="gamesel-list">
{filtered.map(([id, label]) => (
<button
type="button" key={id}
className={`gamesel-item${id === value ? ' active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onChange(id); setOpen(false); }}
>
<span className="gamesel-label">{label}</span>
<span className="gamesel-id">{id}</span>
</button>
))}
</div>
)}
</div>
);
}
/**
* Durchsuchbare Auswahl für Kanäle und Rollen — dieselbe Bedienung wie bei den
* gamedig-IDs. Ein natives <select> lässt sich nicht durchsuchen; bei vielen
* Kanälen scrollt man sonst durch eine lange Liste.
*
* @param {{
* items: Array<{ id: string, label: string, hint?: string }>,
* value: string, onChange: (id: string) => void, empty: string,
* }} props — `empty` ist die Beschriftung für „nichts gewählt"
*/
function PickSelect({ items, value, onChange, empty }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const wrapRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
setOpen(false);
setQuery('');
}
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const current = items.find((i) => i.id === value);
const q = query.trim().toLowerCase();
const filtered = q
? items.filter((i) => `${i.label} ${i.hint ?? ''}`.toLowerCase().includes(q))
: items;
return (
<div className="picksel" ref={wrapRef}>
{/* Zugeklappt steht die Auswahl im Feld, aufgeklappt tippt man die Suche */}
<input
type="text"
value={open ? query : (current?.label ?? '')}
placeholder={current ? current.label : empty}
onFocus={() => { 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();
}
}}
/>
<span className="picksel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>
<IconChevronDown size={14} />
</span>
{open && (
<div className="picksel-list">
<button
type="button" className={`picksel-item${value === '' ? ' active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onChange(''); setOpen(false); setQuery(''); }}
>
<span className="picksel-label muted">{empty}</span>
</button>
{filtered.map((i) => (
<button
type="button" key={i.id}
className={`picksel-item${i.id === value ? ' active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onChange(i.id); setOpen(false); setQuery(''); }}
>
<span className="picksel-label">{i.label}</span>
{i.hint && <span className="picksel-hint">{i.hint}</span>}
</button>
))}
{filtered.length === 0 && <span className="picksel-none">Nichts gefunden</span>}
</div>
)}
</div>
);
}
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
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', '🔧 Garrys 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([]);
const [serverStats, setServerStats] = useState(null);
const [watchdog, setWatchdog] = useState(null);
const [automod, setAutomod] = useState(null);
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/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); 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/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);
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);
// 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 = (
<section className="page-header">
<div className="page-bg-text">CONFIG</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Admin</div>
<h1 className="page-title">Config</h1>
<p className="page-subtitle">Alles an einem Ort Änderungen greifen sofort.</p>
</div>
</section>
);
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
return (
<>
{header}
<section className="content">
<div className="notice center">
<p className="with-icon"><IconLock /> Nur für den Admin.</p>
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
</div>
</section>
</>
);
}
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
const channelItems = data.channels.map((c) => ({ id: c.id, label: `#${c.name}`, hint: 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 <option>-Elemente
const channelOptions = channelItems.map((c) => (
<option key={c.id} value={c.id}>{c.label} {c.hint}</option>
));
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 = (
<>
<div className="settings-section">
<div className="dash-head">
{data.status.botAvatar
? <img className="dash-avatar" src={data.status.botAvatar} alt="" />
: <span className="dash-avatar leer">?</span>}
<div className="dash-ident">
<span className="dash-name">
{data.status.botName ?? 'Bot'}
{data.status.botTag && <span className="dash-tag">{data.status.botTag}</span>}
</span>
<span className="dash-pill"><span className="dash-dot" /> online</span>
</div>
<span className="dash-uptime">
seit {fmtUptime(data.status.uptimeSeconds)}
{data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
</span>
</div>
<div className="dash-kpis">
<div className="card dash-kpi">
<span className="dash-v">{activeModules.length}<span className="dash-of">/{modules.length || ''}</span></span>
<span className="dash-l">Module an</span>
<span className={`dash-t${unfinished.length > 0 ? ' warn' : ' gut'}`}>
{unfinished.length > 0 ? `${unfinished.length} unvollständig` : 'alle einsatzbereit'}
</span>
</div>
<div className="card dash-kpi">
<span className="dash-v">{serverStats?.members != null ? serverStats.members.toLocaleString('de-DE') : '—'}</span>
<span className="dash-l">Member</span>
<span className={`dash-t${memberDelta > 0 ? ' gut' : ''}`}>
{memberDelta == null
? ' '
: `${memberDelta >= 0 ? '+' : ''}${memberDelta} in 7 Tagen`}
</span>
</div>
<div className="card dash-kpi">
<span className="dash-v">{data.status.devlogs}</span>
<span className="dash-l">Devlogs</span>
<span className="dash-t">{seit(data.status.lastDevlog, 'noch keins')}</span>
</div>
<div className="card dash-kpi">
<span className="dash-v">{fmtBytes(data.status.dbSizeBytes)}</span>
<span className="dash-l">Datenbank</span>
<span className={`dash-t${backupTage == null || backupTage > 2 ? ' warn' : ' gut'}`}>
Backup {seit(data.status.lastBackup, 'fehlt')}
</span>
</div>
</div>
</div>
{aktivitaet && aktivitaet.gesamt > 0 && (
<div className="settings-section">
<h2 className="settings-title">
// Aktivität — {aktivitaet.gesamt.toLocaleString('de-DE')} Nachrichten in 30 Tagen
</h2>
<div className="dash-spark">
{aktivitaet.tage.map((t) => (
<span
key={t.key}
className="dash-bar"
style={{ height: `${Math.max(3, (t.n / aktivitaet.max) * 100)}%` }}
title={`${t.key}: ${t.n} Nachrichten`}
/>
))}
</div>
</div>
)}
{modules.length > 0 && moduleGroups.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Module nach Bereich</h2>
<div className="dash-groups">
{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 (
<button
className="dash-group"
key={g.id}
onClick={() => setTab('module')}
title={`${g.label} im Modul-Überblick öffnen`}
>
<span className="dash-group-name">{g.label}</span>
<span className={`dash-group-bar${offen > 0 ? ' warn' : ''}`}>
<i style={{ width: `${(an / drin.length) * 100}%` }} />
</span>
<span className="dash-group-n">{an}/{drin.length}</span>
</button>
);
})}
</div>
</div>
)}
{modules.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Noch einzurichten</h2>
{unfinished.length === 0 ? (
<p className="section-intro todo-done with-icon">
<IconCheck size={15} /> Alles eingerichtet jedes eingeschaltete Modul hat, was es braucht.
</p>
) : (
<>
<p className="section-intro">
Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas
bis dahin tun sie nichts. Einzeln anlegen, oder alles auf einmal.
</p>
{/* Grundgerüst: erst Vorschau, dann anlegen */}
{!plan && (
<div className="settings-actions" style={{ marginBottom: '1.1rem' }}>
<button className="btn" onClick={loadPlan}>
Alles auf einmal anlegen
</button>
</div>
)}
{plan && (
<div className="setup-plan">
<p className="setup-plan-intro">
{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 <strong>{plan.kategorie}</strong>.
</p>
<div className="setup-plan-list">
{plan.schritte.map((s) => (
<div className={`setup-step${s.vorhanden ? ' da' : ''}`} key={s.setting}>
<span className="setup-step-name">
{s.art === 'rolle' ? '@' : '#'}{s.name}
</span>
<span className="setup-step-note">
{s.vorhanden ? 'vorhanden — wird verknüpft' : s.privat ? 'neu, privat' : 'neu'}
</span>
<span className="setup-step-for">{s.label}</span>
</div>
))}
</div>
<div className="settings-actions">
<button className="btn btn-save" disabled={setupRunning} onClick={runSetup}>
{setupRunning ? 'Legt an …' : 'Jetzt anlegen'}
</button>
<button className="btn btn-ghost" disabled={setupRunning} onClick={() => setPlan(null)}>
Abbrechen
</button>
</div>
</div>
)}
{setupResult && (
<div className="setup-plan">
<p className="setup-plan-intro">Ergebnis:</p>
<div className="setup-plan-list">
{setupResult.map((r) => (
<div className={`setup-step${r.ok ? '' : ' fehler'}`} key={r.setting}>
<span className="setup-step-name">{r.name ?? r.label}</span>
<span className="setup-step-note">
{r.ok
? (r.wiederverwendet ? 'verknüpft' : 'angelegt')
: r.fehler}
</span>
</div>
))}
</div>
<div className="settings-actions">
<button className="btn btn-ghost" onClick={() => setSetupResult(null)}>Schließen</button>
</div>
</div>
)}
<div className="key-list">
{unfinished.map((m) => (
<div className="key-row" key={m.id}>
<span className="key-name">{m.name}</span>
<span className="todo-missing with-icon">
<IconWarning size={13} /> {m.missing.map((x) => x.label).join(', ')}
</span>
{/* Was der Bot selbst anlegen kann, bekommt einen Knopf dafür */}
{m.missing.filter((x) => x.create).map((x) => (
<button
key={x.setting}
className="btn-mini btn-create"
disabled={creating === x.setting}
title={`#${x.create.name} anlegen und eintragen`}
onClick={() => createMissing(x)}
>
{creating === x.setting
? '…'
: `${x.create.rolle ? 'Rolle' : 'Kanal'} anlegen`}
</button>
))}
<button
className="btn-mini"
title={`Zum Feld „${m.missing[0].label}" springen`}
onClick={() => jumpToMissing(m.missing[0], m.tab)}
>
Einrichten
</button>
</div>
))}
</div>
</>
)}
</div>
)}
</>
);
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
const tabBrand = (
<>
<div className="settings-section bot-card">
<div className="bot-card-left">
<div className="bot-avatar-wrap">
{data.status.botAvatar
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
: <div className="bot-avatar bot-avatar-empty"><IconBot size={30} /></div>}
<span className="bot-dot" style={{ background: statusDot }} />
</div>
<label className="btn btn-upload">
<IconUpload size={14} /> Bild ändern
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
</label>
<label className="btn btn-upload btn-ghost">
<IconUpload size={14} /> Banner
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
</label>
</div>
<div className="bot-card-right">
<div className="field" data-setting="bot_name">
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
</div>
<div className="field">
<label>Beschreibung <span className="field-hint">Über mich" im Bot-Profil (max 400 Zeichen)</span></label>
<textarea rows={3} maxLength={400} placeholder="z. B. Community-Bot für EcoGame Devlogs, Level, Tickets & mehr. 🤖" value={botDesc} onChange={(e) => setBotDesc(e.target.value)} />
</div>
<div className="field" data-setting="bot_presence_status">
<label>Bot-Status</label>
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
<option value="online">🟢 Online</option>
<option value="idle">🌙 Abwesend</option>
<option value="dnd">⛔ Bitte nicht stören</option>
</select>
</div>
<div className="field" data-setting="bot_status_text">
<label>Aktivität <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
<div className="field-row">
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
<option value="custom">Status</option>
<option value="playing">Spielt</option>
<option value="watching">Schaut</option>
<option value="listening">Hört</option>
</select>
<input type="text" placeholder="z. B. ⛏️ EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
</div>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Embeds</h2>
<div className="field" data-setting="brand_name">
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
</div>
<div className="field" data-setting="brand_color">
<label>Embed-Farben <span className="field-hint">primär (Embeds) · sekundär (Releases, Mod-Aktionen)</span></label>
<div className="field-row">
<input type="color" className="color-pick" value={form.brand_color} onChange={(e) => setForm({ ...form, brand_color: e.target.value })} />
<input type="color" className="color-pick" value={form.brand_color2} onChange={(e) => setForm({ ...form, brand_color2: e.target.value })} />
<span className="field-hint" style={{ alignSelf: 'center' }}>{form.brand_color} · {form.brand_color2}</span>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Aus der Env verlagert</h2>
<div className="field" data-setting="discord_guild_id">
<label>Guild-ID <span className="field-hint">für sofortige Slash-Commands; greift beim nächsten Neustart</span></label>
<input type="text" placeholder="Server-ID" value={form.discord_guild_id} onChange={(e) => setForm({ ...form, discord_guild_id: e.target.value })} />
</div>
<div className="field" data-setting="gitea_api_token">
<label>Gitea-API-Token <span className="field-hint">{form.gitea_api_token_set ? '✓ gesetzt — neuen Wert eingeben zum Ersetzen' : 'für /bug; wird nie wieder angezeigt'}</span></label>
<input type="password" placeholder="Token (write-only)" value={giteaToken} onChange={(e) => setGiteaToken(e.target.value)} />
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={saveBrand}>Speichern &amp; anwenden</button>
</div>
</>
);
const tabFeeds = (
<>
<div className="settings-section">
<h2 className="settings-title">// Kanäle</h2>
<div className="field" data-setting="devlog_channel_id">
<label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label>
<div className="field-row">
<PickSelect
items={channelItems}
value={form.devlog_channel_id ?? ''}
onChange={(v) => setForm({ ...form, devlog_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
</div>
</div>
<div className="field" data-setting="commit_channel_id">
<label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label>
<div className="field-row">
<PickSelect
items={channelItems}
value={form.commit_channel_id ?? ''}
onChange={(v) => setForm({ ...form, commit_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('commit')}>Test</button>
</div>
</div>
<div className="field" data-setting="release_channel_id">
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases leer = aus</span></label>
<div className="field-row">
<PickSelect
items={channelItems}
value={form.release_channel_id ?? ''}
onChange={(v) => setForm({ ...form, release_channel_id: v })}
empty="— deaktiviert —"
/>
<button className="btn" onClick={() => sendTest('release')}>Test</button>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Devlog</h2>
<div className="field" data-setting="devlog_ping_role_id">
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post leer = kein Ping</span></label>
<PickSelect
items={roleItems}
value={form.devlog_ping_role_id ?? ''}
onChange={(v) => setForm({ ...form, devlog_ping_role_id: v })}
empty="— kein Ping —"
/>
</div>
<div className="field" data-setting="weekly_recap_enabled">
<label className="toggle">
<input type="checkbox" checked={form.weekly_recap_enabled} onChange={(e) => setForm({ ...form, weekly_recap_enabled: e.target.checked })} />
<span>Wochen-Rückblick (sonntags 20:00)</span>
<span className="field-hint">Commits pro Tag, Devlogs & Top-Projekte</span>
</label>
</div>
<div className="field" data-setting="devlog_threads_enabled">
<label className="toggle">
<input type="checkbox" checked={form.devlog_threads_enabled} onChange={(e) => setForm({ ...form, devlog_threads_enabled: e.target.checked })} />
<span>Diskussions-Thread unter jedem Devlog</span>
<span className="field-hint">„💬 Devlog &lt;Datum&gt;"</span>
</label>
</div>
<div className="field">
<button className="btn" onClick={() => sendTest('recap')}>Rückblick jetzt testen</button>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Commit-Feed</h2>
<div className="field" data-setting="commit_feed_enabled">
<label className="toggle">
<input type="checkbox" checked={form.commit_feed_enabled} onChange={(e) => setForm({ ...form, commit_feed_enabled: e.target.checked })} />
<span>Push-Embeds posten</span>
<span className="field-hint">aus = weiter archivieren, nur nicht posten</span>
</label>
</div>
<div className="field" data-setting="commit_branch_filter">
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle</span></label>
<input type="text" placeholder="z. B. main, release" value={form.commit_branch_filter} onChange={(e) => setForm({ ...form, commit_branch_filter: e.target.value })} />
</div>
<div className="field" data-setting="ignored_repos">
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen</span></label>
<input type="text" placeholder="z. B. D4rkst3r/d4rkbot" value={form.ignored_repos} onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })} />
</div>
</div>
</>
);
const tabCommunity = (
<>
<div className="settings-section">
<h2 className="settings-title">// Geburtstage</h2>
<div className="field" data-setting="birthday_channel_id">
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
<PickSelect
items={channelItems}
value={form.birthday_channel_id ?? ''}
onChange={(v) => setForm({ ...form, birthday_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field" data-setting="birthday_role_id">
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
<PickSelect
items={roleItems}
value={form.birthday_role_id ?? ''}
onChange={(v) => setForm({ ...form, birthday_role_id: v })}
empty=" keine Rolle "
/>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Playtester</h2>
<div className="field" data-setting="playtester_role_id">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<PickSelect
items={roleItems}
value={form.playtester_role_id ?? ''}
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
empty=" deaktiviert "
/>
</div>
{playtesters.length > 0 && (
<div className="key-list">
{playtesters.map((p) => (
<div className="key-row" key={p.user_id}>
<span className="key-name">{p.username}</span>
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
</div>
))}
</div>
)}
</div>
<div className="settings-section">
<h2 className="settings-title">// Alpha-Keys</h2>
<div className="stats" style={{ marginBottom: '1.2rem' }}>
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Frei</span></div>
<div className="stat"><span className="stat-value">{alphaKeys?.assigned?.length ?? '—'}</span><span className="stat-label">Vergeben</span></div>
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
</div>
<div className="field">
<label>Keys hinzufügen <span className="field-hint">einer pro Zeile</span></label>
<textarea rows={3} placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'} value={keyUpload} onChange={(e) => setKeyUpload(e.target.value)} />
</div>
<div className="field-row">
<button className="btn" onClick={uploadAlphaKeys}>Hinzufügen</button>
<button
className="btn btn-save"
onClick={distributeKeys}
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
>
<IconKey size={14} /> An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
</button>
</div>
{alphaKeys?.assigned?.length > 0 && (
<div className="key-list" style={{ marginTop: '1rem' }}>
{alphaKeys.assigned.map((k) => (
<div className="key-row" key={k.key}>
<span className="key-name">{k.key}</span>
<span className="key-used">→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}</span>
</div>
))}
</div>
)}
</div>
<div className="settings-section">
<h2 className="settings-title">// Starboard & Galerie</h2>
<div className="field" data-setting="starboard_channel_id">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
<div className="field-row">
<PickSelect
items={channelItems}
value={form.starboard_channel_id ?? ''}
onChange={(v) => setForm({ ...form, starboard_channel_id: v })}
empty=" deaktiviert "
/>
<input type="text" style={{ width: '6rem', flex: 'none' }} title="Schwellwert (Anzahl )" value={form.starboard_threshold} onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })} />
</div>
</div>
<div className="field" data-setting="screenshot_channel_id">
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label>
<PickSelect
items={channelItems}
value={form.screenshot_channel_id ?? ''}
onChange={(v) => setForm({ ...form, screenshot_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field" data-setting="voting_channel_id">
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label>
<PickSelect
items={channelItems}
value={form.voting_channel_id ?? ''}
onChange={(v) => setForm({ ...form, voting_channel_id: v })}
empty=" deaktiviert "
/>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Level-System</h2>
<div className="field" data-setting="levels_enabled">
<label className="toggle">
<input type="checkbox" checked={form.levels_enabled} onChange={(e) => setForm({ ...form, levels_enabled: e.target.checked })} />
<span>XP fürs Schreiben vergeben</span>
<span className="field-hint">1525 XP pro Nachricht, 60s Cooldown; /rank + Bestenliste auf /level</span>
</label>
</div>
<div className="field" data-setting="levels_announce">
<label className="toggle">
<input type="checkbox" checked={form.levels_announce} onChange={(e) => setForm({ ...form, levels_announce: e.target.checked })} />
<span>Level-Up im Kanal feiern</span>
<span className="field-hint">„🎉 Name ist jetzt Level 5!"</span>
</label>
</div>
<div className="field" data-setting="level_rewards">
<label>Rollen-Belohnungen <span className="field-hint">Format: Level=Rollen-ID, kommagetrennt z. B. 5=1234, 10=5678</span></label>
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
</div>
</div>
</>
);
const tabRollen = (
<>
<div className="settings-section">
<h2 className="settings-title">// Rollen bei Join</h2>
<div className="field" data-setting="autorole_id">
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch leer = aus</span></label>
<PickSelect
items={roleItems}
value={form.autorole_id ?? ''}
onChange={(v) => setForm({ ...form, autorole_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field" data-setting="sticky_roles_enabled">
<label className="toggle">
<input type="checkbox" checked={form.sticky_roles_enabled} onChange={(e) => setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
<span>Sticky-Roles</span>
<span className="field-hint">wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder</span>
</label>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={save}>Speichern</button>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Rollen-Menüs</h2>
<p className="section-intro">
Selbstbedienungs-Rollen wie bei Carl-bot nur ohne Paywall. Der Bot postet
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
jederzeit editierbar, der Discord-Post zieht automatisch mit.
</p>
{menus.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{menus.map((m) => (
<div className="key-row" key={m.id}>
<span className="key-name">{m.title}</span>
<span className="key-scopes">
{m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
{m.exclusive ? ' · exklusiv' : ''}
</span>
<span className="key-used">
{m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
</span>
<button className="btn btn-mini" onClick={() => editMenu(m)}>Bearbeiten</button>
<button className="card-delete" title="Menü + Discord-Post löschen" onClick={() => removeMenu(m.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
<div className="field-row">
<input type="text" placeholder="Titel, z. B. 🎭 Hol dir deine Rollen" value={menuDraft.title} onChange={(e) => setMenuDraft({ ...menuDraft, title: e.target.value })} />
<PickSelect
items={channelItems}
value={menuDraft.channel_id}
onChange={(v) => setMenuDraft({ ...menuDraft, channel_id: v })}
empty="— Kanal wählen —"
/>
</div>
</div>
<div className="field">
<label>Beschreibung <span className="field-hint">optional, steht im Embed über den Buttons</span></label>
<input type="text" placeholder="z. B. Wähl aus, wofür du Pings bekommen willst" value={menuDraft.description} onChange={(e) => setMenuDraft({ ...menuDraft, description: e.target.value })} />
</div>
<div className="field">
<label className="toggle">
<input type="checkbox" checked={menuDraft.exclusive} onChange={(e) => setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
<span>Exklusiv</span>
<span className="field-hint">max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)</span>
</label>
</div>
<div className="field">
<label>Rollen <span className="field-hint">Emoji optional (auch Custom als &lt;:name:id&gt;), Label = Button-Text</span></label>
{menuDraft.entries.map((entry, i) => (
<div className="rr-row" key={i}>
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
<PickSelect
items={roleItems}
value={entry.role_id}
onChange={(v) => setMenuEntry(i, { role_id: v })}
empty="— Rolle —"
/>
<select
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
title="Button-Farbe"
value={entry.style ?? 'secondary'}
onChange={(e) => setMenuEntry(i, { style: e.target.value })}
>
<option value="secondary"> Grau</option>
<option value="primary">🟦 Blau</option>
<option value="success">🟩 Grün</option>
<option value="danger">🟥 Rot</option>
</select>
<button
className="card-delete"
title="Zeile entfernen"
onClick={() => setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
disabled={menuDraft.entries.length === 1}
></button>
</div>
))}
<div className="field-row" style={{ marginTop: '.6rem' }}>
<button className="btn" onClick={() => setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
+ Rolle
</button>
<button className="btn" onClick={() => saveMenu(false)}>Speichern</button>
<button className="btn btn-save" onClick={() => saveMenu(true)}>Speichern + posten</button>
{menuDraft.id && (
<button className="btn btn-ghost" onClick={() => setMenuDraft(emptyMenu)}>Abbrechen</button>
)}
</div>
</div>
</div>
</>
);
/** AutoMod-Regel an- oder abschalten */
async function automodSchalten(id, aktiv) {
try {
await apiPut(`/api/automod/${id}`, { aktiv });
const frisch = await apiGet('/api/automod');
setAutomod(frisch);
} catch (e) {
setFeedback(e.body?.error ?? 'Umschalten fehlgeschlagen.');
setTimeout(() => setFeedback(null), 3000);
}
}
const tabSupport = (
<>
{automod && (
<div className="settings-section">
<h2 className="settings-title">// AutoMod</h2>
<p className="section-intro">
Discords eigener Filter er läuft serverseitig und ohne Umweg über den Bot.
Hier siehst du, was eingerichtet ist, und kannst Regeln schalten.
Wortlisten bearbeitest du in Discord unter <strong>Servereinstellungen AutoMod</strong>.
{!automod.aktiv && ' Das Protokollieren der Treffer ist gerade ausgeschaltet.'}
</p>
{!automod.ok && (
<p className="notice">
Regeln nicht abrufbar: {automod.grund}
{' '} fehlt dem Bot das Recht Server verwalten"?
</p>
)}
{automod.ok && automod.regeln.length === 0 && (
<p className="notice">
Noch keine AutoMod-Regel eingerichtet. Anlegen geht in Discord unter
{' '}<strong>Servereinstellungen → AutoMod</strong>.
</p>
)}
{automod.regeln.map((r) => (
<div className="am-row card" key={r.id}>
<div className="am-head">
<span className={`wd-dot ${r.aktiv ? 'an' : 'wartet'}`} />
<span className="st-name">{r.name}</span>
<span className="tm-chip">{r.ausloeser}</span>
<label className="toggle am-toggle">
<input
type="checkbox"
checked={r.aktiv}
onChange={(e) => automodSchalten(r.id, e.target.checked)}
/>
<span>{r.aktiv ? 'aktiv' : 'aus'}</span>
</label>
</div>
<div className="am-meta">
{r.details.length > 0 && <span>{r.details.join(' · ')}</span>}
<span className="am-actions">
{r.aktionen.map((a) => <span className="tm-chip" key={a}>{a}</span>)}
</span>
{(r.ausnahmen.rollen > 0 || r.ausnahmen.kanaele > 0) && (
<span className="am-exempt">
ausgenommen: {[
r.ausnahmen.rollen > 0 && `${r.ausnahmen.rollen} ${r.ausnahmen.rollen === 1 ? 'Rolle' : 'Rollen'}`,
r.ausnahmen.kanaele > 0 && `${r.ausnahmen.kanaele} ${r.ausnahmen.kanaele === 1 ? 'Kanal' : 'Kanäle'}`,
].filter(Boolean).join(', ')}
</span>
)}
</div>
</div>
))}
</div>
)}
<div className="settings-section">
<h2 className="settings-title">// Moderation &amp; Kontakt</h2>
<div className="field" data-setting="modmail_channel_id">
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads</span></label>
<PickSelect
items={channelItems}
value={form.modmail_channel_id ?? ''}
onChange={(v) => setForm({ ...form, modmail_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field" data-setting="ticket_channel_id">
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label>
<PickSelect
items={channelItems}
value={form.ticket_channel_id ?? ''}
onChange={(v) => setForm({ ...form, ticket_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field" data-setting="welcome_channel_id">
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
<PickSelect
items={channelItems}
value={form.welcome_channel_id ?? ''}
onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field" data-setting="modlog_channel_id">
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
<PickSelect
items={channelItems}
value={form.modlog_channel_id ?? ''}
onChange={(v) => setForm({ ...form, modlog_channel_id: v })}
empty=" deaktiviert "
/>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Willkommens-Karte</h2>
<p className="section-intro">
Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
</p>
<div className="field" data-setting="welcome_card_enabled">
<label className="toggle">
<input
type="checkbox"
checked={form.welcome_card_enabled}
onChange={(e) => setForm({ ...form, welcome_card_enabled: e.target.checked })}
/>
<span>Karte rendern</span>
<span className="field-hint">aus = nur das Text-Embed mit Avatar als Vorschaubild</span>
</label>
</div>
{form.welcome_card_enabled && (
<>
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
<div className="wc-colors">
{[
['welcome_card_bg', 'Hintergrund'],
['welcome_card_accent', 'Akzent 1'],
['welcome_card_accent2', 'Akzent 2'],
['welcome_card_text', 'Name'],
].map(([key, label]) => (
<label className="wc-color" key={key}>
<input
className="color-pick"
type="color"
value={form[key] ?? '#000000'}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
<span>{label}</span>
</label>
))}
</div>
<div className="field" data-setting="welcome_card_kicker">
<label>Kleine Zeile <span className="field-hint">über dem Namen — leer = weglassen</span></label>
<input
type="text" maxLength={24} placeholder="WILLKOMMEN"
value={form.welcome_card_kicker ?? ''}
onChange={(e) => setForm({ ...form, welcome_card_kicker: e.target.value })}
/>
</div>
<div className="field" data-setting="welcome_card_sub">
<label>Zeile darunter <span className="field-hint">{'{count}'} wird zur Mitglieds-Nummer — leer = weglassen</span></label>
<input
type="text" maxLength={60} placeholder="Member #{count}"
value={form.welcome_card_sub ?? ''}
onChange={(e) => setForm({ ...form, welcome_card_sub: e.target.value })}
/>
</div>
<div className="field" data-setting="welcome_card_image">
<label>Hintergrundbild <span className="field-hint">https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster</span></label>
<input
type="text" placeholder="https://cdn.d4rkst3r.de/welcome.png"
value={form.welcome_card_image ?? ''}
onChange={(e) => setForm({ ...form, welcome_card_image: e.target.value })}
/>
</div>
<div className="field" data-setting="welcome_card_watermark">
<label className="toggle">
<input
type="checkbox"
checked={form.welcome_card_watermark}
onChange={(e) => setForm({ ...form, welcome_card_watermark: e.target.checked })}
/>
<span>Markenname im Hintergrund</span>
<span className="field-hint">großer, schwach sichtbarer Schriftzug hinten rechts</span>
</label>
</div>
</>
)}
</div>
</>
);
const tabBewerbungen = (
<div className="settings-section">
<h2 className="settings-title">// Bewerbungs-Formulare</h2>
<p className="section-intro">
Button-Post Discord-Formular (bis 5 Fragen) Review mit / im Staff-Kanal.
Annahme vergibt optional eine Rolle z. B. für FiveM-Whitelist oder Team-Bewerbungen.
</p>
{appForms.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{appForms.map((f) => (
<div className="key-row" key={f.id}>
<span className="key-name">{f.title}</span>
<span className="key-scopes">{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}</span>
<button className="btn btn-mini" onClick={() => setFormDraft({
id: f.id, title: f.title, description: f.description,
review_channel_id: f.review_channel_id ?? '', approve_role_id: f.approve_role_id ?? '',
post_channel_id: f.post_channel_id ?? '',
questions: f.questions.length ? f.questions : [''],
})}>Bearbeiten</button>
<button className="card-delete" title="Formular + Post löschen" onClick={() => removeForm(f.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{formDraft.id ? `Formular #${formDraft.id} bearbeiten` : 'Neues Formular'}</label>
<div className="field-row">
<input type="text" placeholder="Titel, z. B. 🎮 FiveM-Whitelist" value={formDraft.title} onChange={(e) => setFormDraft({ ...formDraft, title: e.target.value })} />
</div>
</div>
<div className="field">
<input type="text" placeholder="Beschreibung überm Button (optional)" value={formDraft.description} onChange={(e) => setFormDraft({ ...formDraft, description: e.target.value })} />
</div>
<div className="field">
<div className="field-row">
<PickSelect
items={channelItems}
value={formDraft.post_channel_id}
onChange={(v) => setFormDraft({ ...formDraft, post_channel_id: v })}
empty="— Post-Kanal (öffentlich) —"
/>
<PickSelect
items={channelItems}
value={formDraft.review_channel_id}
onChange={(v) => setFormDraft({ ...formDraft, review_channel_id: v })}
empty="— Review-Kanal (Staff) —"
/>
<PickSelect
items={roleItems}
value={formDraft.approve_role_id}
onChange={(v) => setFormDraft({ ...formDraft, approve_role_id: v })}
empty="— Rolle bei Annahme —"
/>
</div>
</div>
<div className="field">
<label>Fragen <span className="field-hint">max 5 (Discord-Modal-Limit)</span></label>
{formDraft.questions.map((q, i) => (
<div className="rr-row" key={i}>
<input type="text" placeholder={`Frage ${i + 1}`} maxLength={45} value={q} onChange={(e) => setFormDraft({
...formDraft,
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
})} />
<button className="card-delete" title="Frage entfernen" disabled={formDraft.questions.length === 1}
onClick={() => setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}></button>
</div>
))}
<div className="field-row" style={{ marginTop: '.6rem' }}>
<button className="btn" disabled={formDraft.questions.length >= 5}
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage</button>
<button className="btn" onClick={() => saveForm(false)}>Speichern</button>
<button className="btn btn-save" onClick={() => saveForm(true)}>Speichern + posten</button>
{formDraft.id && <button className="btn btn-ghost" onClick={() => setFormDraft(emptyForm)}>Abbrechen</button>}
</div>
</div>
</div>
);
const tabComposer = (
<>
<EmbedComposer channelOptions={channelOptions} flash={flash} />
<div className="settings-section">
<h2 className="settings-title">// Geplante Posts</h2>
{scheduled.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{scheduled.map((p) => (
<div className="key-row" key={p.id}>
<span className="key-name">{channelName(p.channel_id)}</span>
<span className="key-scopes">
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
{p.enabled ? '' : ' · erledigt'}
</span>
<span className="key-used" title={p.content}>{p.content.slice(0, 40)}</span>
<button className="card-delete" title="Löschen" onClick={() => removeSchedule(p.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>Neuer geplanter Post</label>
<div className="field-row">
<PickSelect
items={channelItems}
value={schedDraft.channel_id}
onChange={(v) => setSchedDraft({ ...schedDraft, channel_id: v })}
empty="— Kanal wählen —"
/>
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
<option value="once">einmalig</option>
<option value="daily">täglich</option>
<option value="weekly">wöchentlich</option>
</select>
</div>
</div>
<div className="field">
<div className="field-row">
{schedDraft.type === 'once' && (
<input type="date" value={schedDraft.date} onChange={(e) => setSchedDraft({ ...schedDraft, date: e.target.value })} />
)}
{schedDraft.type === 'weekly' && (
<select value={schedDraft.weekday} onChange={(e) => setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
<option key={i} value={String(i)}>{d}</option>
))}
</select>
)}
<input type="time" value={schedDraft.time} onChange={(e) => setSchedDraft({ ...schedDraft, time: e.target.value })} />
</div>
</div>
<div className="field">
<input type="text" placeholder="Embed-Titel (leer = normale Nachricht)" value={schedDraft.embed_title} onChange={(e) => setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
</div>
<div className="field">
<textarea rows={4} placeholder="Text (Discord-Markdown)" value={schedDraft.content} onChange={(e) => setSchedDraft({ ...schedDraft, content: e.target.value })} />
</div>
<div className="field">
<button className="btn btn-save" onClick={createSchedule}>Planen</button>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Triggers</h2>
<p className="section-intro">
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
</p>
{triggers.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{triggers.map((t) => (
<div className="key-row" key={t.keyword}>
<span className="key-name">{t.keyword}</span>
<span className="key-used" title={t.reply}>{t.reply.slice(0, 50)}</span>
<button className="btn btn-mini" onClick={() => setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten</button>
<button className="card-delete" title="Trigger löschen" onClick={() => removeTrigger(t.keyword)}></button>
</div>
))}
</div>
)}
<div className="field">
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="Keyword (min 3 Zeichen)" value={triggerDraft.keyword} onChange={(e) => setTriggerDraft({ ...triggerDraft, keyword: e.target.value })} />
<input type="text" placeholder="Antwort des Bots" value={triggerDraft.reply} onChange={(e) => setTriggerDraft({ ...triggerDraft, reply: e.target.value })} />
<button className="btn" onClick={saveTriggerDraft}>Speichern</button>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Tags (/tag)</h2>
<p className="section-intro">
Text-Bausteine für wiederkehrende Antworten jeder Member kann sie mit <code>/tag name</code> abrufen.
</p>
{tags.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{tags.map((t) => (
<div className="key-row" key={t.name}>
<span className="key-name">{t.name}</span>
<span className="key-used" title={t.content}>{t.content.slice(0, 50)}</span>
<button className="btn btn-mini" onClick={() => setTagDraft({ name: t.name, content: t.content })}>Bearbeiten</button>
<button className="card-delete" title="Tag löschen" onClick={() => removeTag(t.name)}></button>
</div>
))}
</div>
)}
<div className="field">
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="name (z. B. server-ip)" value={tagDraft.name} onChange={(e) => setTagDraft({ ...tagDraft, name: e.target.value })} />
<input type="text" placeholder="Inhalt des Tags" value={tagDraft.content} onChange={(e) => setTagDraft({ ...tagDraft, content: e.target.value })} />
<button className="btn" onClick={saveTagDraft}>Speichern</button>
</div>
</div>
</div>
</>
);
const tabSystem = (
<>
<div className="settings-section">
<h2 className="settings-title">// Allgemein</h2>
<div className="field" data-setting="public_url">
<label>Öffentliche URL <span className="field-hint">Basis für Buttons, RSS, OAuth-Redirect</span></label>
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.public_url} onChange={(e) => setForm({ ...form, public_url: e.target.value })} />
</div>
<div className="field" data-setting="gitea_url">
<label>Gitea-URL</label>
<input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Zwei Seiten</h2>
<p className="section-intro">
Community-Hub und Bot-Produktseite auf getrennte Adressen legen. Solange
beide Felder leer sind, läuft alles unverändert auf der öffentlichen URL.
Sind sie gesetzt, leitet der Server falsch aufgerufene Seiten automatisch
auf die richtige Adresse um.
</p>
<div className="field" data-setting="hub_url">
<label>Community-Hub <span className="field-hint">Devlogs, Roadmap, Server, Profile Ziel für Devlog-Buttons, RSS und Link-Vorschauen</span></label>
<input type="text" placeholder="https://hub.d4rkst3r.de" value={form.hub_url ?? ''} onChange={(e) => setForm({ ...form, hub_url: e.target.value })} />
</div>
<div className="field" data-setting="bot_url">
<label>Bot-Produktseite <span className="field-hint">Funktionen, Befehle und dieses Dashboard</span></label>
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.bot_url ?? ''} onChange={(e) => setForm({ ...form, bot_url: e.target.value })} />
</div>
<div className="field" data-setting="cookie_domain">
<label>Login-Domain <span className="field-hint">z. B. <code>.d4rkst3r.de</code> dann gilt die Anmeldung auf beiden Seiten; leer = getrennte Logins</span></label>
<input type="text" placeholder=".d4rkst3r.de" value={form.cookie_domain ?? ''} onChange={(e) => setForm({ ...form, cookie_domain: e.target.value })} />
</div>
<div className="field" data-setting="bug_report_repo">
<label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</span></label>
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.bug_report_repo} onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })} />
</div>
<div className="field" data-setting="roadmap_repo">
<label>Roadmap-Repo <span className="field-hint">Milestones öffentliche /roadmap</span></label>
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.roadmap_repo} onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Dienste (Portal)</h2>
<p className="section-intro">
Erscheinen als Kacheln auf der Startseite z. B. Gitea, Kanban oder die Cloud.
</p>
{services.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{services.map((s) => (
<div className="key-row" key={s.id}>
<span className="key-name">{s.icon} {s.name}</span>
<span className="key-used" title={s.url}>{s.url}</span>
<button className="btn btn-mini" onClick={() => setServiceDraft({
id: s.id, name: s.name, url: s.url, icon: s.icon,
description: s.description ?? '', sort: s.sort ?? 0,
})}>Bearbeiten</button>
<button className="card-delete" title="Dienst entfernen" onClick={() => removeService(s.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
</div>
<div className="field-row" style={{ marginTop: '.6rem' }}>
<input type="text" placeholder="Kurzbeschreibung für die Kachel" value={serviceDraft.description} onChange={(e) => setServiceDraft({ ...serviceDraft, description: e.target.value })} />
<button className="btn btn-save" onClick={saveService}>{serviceDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serviceDraft.id && <button className="btn btn-ghost" onClick={() => setServiceDraft(emptyService)}>Abbrechen</button>}
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Impressum &amp; Datenschutz</h2>
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
Erscheint auf <code>/impressum</code> und <code>/datenschutz</code>. Für § 5 DDG
ist eine ladungsfähige Anschrift nötig (kein Postfach) nur der Owner sieht
und ändert diese Felder.
</p>
<div className="field" data-setting="legal_name">
<label>Name / Verantwortlicher</label>
<input type="text" placeholder="Vorname Nachname" value={form.legal_name ?? ''} onChange={(e) => setForm({ ...form, legal_name: e.target.value })} />
</div>
<div className="field" data-setting="legal_address">
<label>Anschrift <span className="field-hint">mehrzeilig: Straße, PLZ Ort, Land</span></label>
<textarea rows={3} placeholder={'Musterstraße 1\n12345 Musterstadt\nDeutschland'} value={form.legal_address ?? ''} onChange={(e) => setForm({ ...form, legal_address: e.target.value })} />
</div>
<div className="field" data-setting="legal_email">
<label>Kontakt-E-Mail <span className="field-hint">Pflichtangabe muss erreichbar sein</span></label>
<input type="email" placeholder="kontakt@d4rkst3r.de" value={form.legal_email ?? ''} onChange={(e) => setForm({ ...form, legal_email: e.target.value })} />
</div>
<div className="field">
<label>Weitere Angaben <span className="field-hint">optional: USt-ID, Telefon, Handelsregister </span></label>
<textarea rows={2} placeholder="USt-IdNr.: DE123456789" value={form.legal_extra ?? ''} onChange={(e) => setForm({ ...form, legal_extra: e.target.value })} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Web-Login (Member)</h2>
<div className="field" data-setting="member_gate_enabled">
<label className="toggle">
<input type="checkbox" checked={form.member_gate_enabled} onChange={(e) => setForm({ ...form, member_gate_enabled: e.target.checked })} />
<span>Login nur für Discord-Member</span>
<span className="field-hint">Nicht-Member werden nach dem Discord-Login abgewiesen (braucht gesetzte Guild-ID)</span>
</label>
</div>
<div className="field" data-setting="discord_invite_url">
<label>Discord-Invite-Link <span className="field-hint">wird Abgewiesenen als Zum Discord"-Button angezeigt</span></label>
<input type="text" placeholder="https://discord.gg/…" value={form.discord_invite_url} onChange={(e) => setForm({ ...form, discord_invite_url: e.target.value })} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Events, Social &amp; Voice</h2>
<div className="field" data-setting="events_announce_channel_id">
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
<PickSelect
items={channelItems}
value={form.events_announce_channel_id ?? ''}
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field" data-setting="social_announce_channel_id">
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
<PickSelect
items={channelItems}
value={form.social_announce_channel_id ?? ''}
onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field" data-setting="youtube_channel_id">
<div className="field-row">
<input type="text" placeholder="YouTube-Kanal-ID (UC…)" value={form.youtube_channel_id} onChange={(e) => setForm({ ...form, youtube_channel_id: e.target.value })} />
<input type="text" placeholder="Twitch-Login (z. B. d4rkst3r)" value={form.twitch_channel} onChange={(e) => setForm({ ...form, twitch_channel: e.target.value })} />
</div>
</div>
<div className="field">
<label>Twitch-App-Credentials <span className="field-hint">{form.twitch_creds_set ? '✓ gesetzt' : 'dev.twitch.tv → App registrieren'} write-only</span></label>
<div className="field-row">
<input type="password" placeholder="Client-ID" value={twitchCreds.id} onChange={(e) => setTwitchCreds({ ...twitchCreds, id: e.target.value })} />
<input type="password" placeholder="Client-Secret" value={twitchCreds.secret} onChange={(e) => setTwitchCreds({ ...twitchCreds, secret: e.target.value })} />
</div>
</div>
<div className="field" data-setting="tempvoice_channel_id">
<label>Temp-Voice-Hub <span className="field-hint">Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
<PickSelect
items={channelItems}
value={form.tempvoice_channel_id ?? ''}
onChange={(v) => setForm({ ...form, tempvoice_channel_id: v })}
empty=" deaktiviert "
/>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Backups &amp; Watchdog</h2>
<div className="field" data-setting="backup_enabled">
<label className="toggle">
<input type="checkbox" checked={form.backup_enabled} onChange={(e) => setForm({ ...form, backup_enabled: e.target.checked })} />
<span>Nächtliches DB-Backup (03:00)</span>
<span className="field-hint">gzip, 14 Tage Rotation</span>
</label>
</div>
<div className="field" data-setting="backup_channel_id">
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
<div className="field-row">
<PickSelect
items={channelItems}
value={form.backup_channel_id ?? ''}
onChange={(v) => setForm({ ...form, backup_channel_id: v })}
empty=" nur lokal "
/>
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
</div>
</div>
<div className="field" data-setting="repo_backup_enabled">
<label className="toggle">
<input type="checkbox" checked={form.repo_backup_enabled} onChange={(e) => setForm({ ...form, repo_backup_enabled: e.target.checked })} />
<span>Repo-Backups (04:00)</span>
<span className="field-hint">alle Gitea-Repos als git-Bundle, 14 Tage Rotation — braucht den Gitea-Token</span>
</label>
<div className="field-row" style={{ marginTop: '.5rem' }}>
<button className="btn" onClick={() => sendTest('repobackup')}>Repos jetzt sichern</button>
</div>
</div>
<div className="field" data-setting="watchdog_urls">
<label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — leer = aus</span></label>
<input type="text" placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health" value={form.watchdog_urls} onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })} />
</div>
</div>
{watchdog?.adressen?.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Erreichbarkeit</h2>
<p className="section-intro">
Ein Strich je Stunde über die letzten 7 Tage grün erreichbar, rot
Ausfall, grau keine Messung. Geprüft wird alle {watchdog.intervall} Minuten.
{!watchdog.aktiv && ' Der Wächter ist gerade ausgeschaltet, es kommen keine neuen Messpunkte dazu.'}
</p>
{watchdog.adressen.map((a) => (
<div className="wd-card card" key={a.url}>
<div className="wd-head">
<span className={`wd-dot ${a.online === null ? 'wartet' : a.online ? 'an' : 'aus'}`} />
<span className="st-name">{a.name}</span>
{a.oeffentlich && <span className="tm-chip alle">öffentlich</span>}
<span className="wd-meta">
{a.online === null && 'noch nicht geprüft'}
{a.online === true && `${a.status ?? 'OK'}${a.ms != null ? ` · ${a.ms} ms` : ''}`}
{a.online === false && `seit ${vorWieLange(new Date(a.seit).toISOString())} weg`}
</span>
<button
className="btn btn-mini"
onClick={() => setDienstDraft({
id: a.id, name: a.name, url: a.url,
gruppe: a.gruppe, oeffentlich: a.oeffentlich,
})}
>
Bearbeiten
</button>
<button className="card-delete" title="Nicht mehr überwachen" onClick={() => dienstLoeschen(a.id, a.name)}></button>
</div>
<a className="wd-url" href={a.url} target="_blank" rel="noreferrer">{a.url}</a>
<UptimeTrack buckets={a.uptime} />
</div>
))}
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>
{dienstDraft.id ? 'Dienst bearbeiten' : 'Dienst hinzufügen'}
</h3>
<div className="field">
<label>Name <span className="field-hint">steht so auf der öffentlichen Statusseite</span></label>
<input type="text" maxLength={60} placeholder="z. B. Gitea" value={dienstDraft.name}
onChange={(e) => setDienstDraft({ ...dienstDraft, name: e.target.value })} />
</div>
<div className="field">
<label>Adresse <span className="field-hint">wird geprüft; erscheint nie öffentlich</span></label>
<input type="text" placeholder="https://git.d4rkst3r.de" value={dienstDraft.url}
onChange={(e) => setDienstDraft({ ...dienstDraft, url: e.target.value })} />
</div>
<div className="field">
<label className="toggle">
<input type="checkbox" checked={dienstDraft.oeffentlich}
onChange={(e) => setDienstDraft({ ...dienstDraft, oeffentlich: e.target.checked })} />
Auf der öffentlichen Statusseite zeigen
</label>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={dienstSpeichern}>
{dienstDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
</button>
{dienstDraft.id && (
<button className="btn btn-ghost" onClick={() => setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false })}>
Abbrechen
</button>
)}
</div>
</div>
)}
{watchdog?.stoerungen?.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Störungen</h2>
<div className="st-incidents">
{watchdog.stoerungen.slice(0, 20).map((v) => (
<div className={`st-incident${v.beendet ? '' : ' offen'}`} key={v.id}>
<span className="st-inc-name">
{watchdog.adressen.find((a) => a.url === v.url)?.name ?? v.url}
</span>
<span className="st-inc-time">{vorWieLange(v.begonnen)}</span>
<span className="st-inc-dur">{v.beendet ? `${v.minuten} min` : 'läuft noch'}</span>
</div>
))}
</div>
</div>
)}
</>
);
const tabApi = (
<>
<div className="settings-section">
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
{keys.length > 0 && (
<div className="key-list">
{keys.map((k) => (
<div className="key-row" key={k.id}>
<span className="key-name">{k.name}</span>
<span className="key-scopes">{k.scopes}</span>
<span className="key-used">
{k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
</span>
<button className="card-delete" title="Key widerrufen" onClick={() => revokeKey(k.id)}></button>
</div>
))}
</div>
)}
{createdKey && (
<div className="key-created">
<p>Key <strong>{createdKey.name}</strong> erstellt <strong>jetzt kopieren</strong>, er wird nie wieder angezeigt:</p>
<code>{createdKey.key}</code>
</div>
)}
<div className="field">
<label>Neuer Key <span className="field-hint">Name des Skripts/Dienstes + Scopes</span></label>
<div className="field-row">
<input type="text" placeholder="z. B. devlog.py, platform, fivem" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} />
<button className="btn" onClick={createKey}>Erstellen</button>
</div>
<div className="scope-row">
{API_SCOPES.map((s) => (
<label className="scope-pick" key={s.id} title={s.hint}>
<input
type="checkbox"
checked={newKeyScopes.includes(s.id)}
onChange={(e) =>
setNewKeyScopes(
e.target.checked
? [...newKeyScopes, s.id]
: newKeyScopes.filter((x) => x !== s.id)
)
}
/>
{s.label}
</label>
))}
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Single Sign-On</h2>
<p className="section-intro">
Andere Dienste (Kanban, Platform ) können den Discord-Login mitnutzen: Sie
leiten auf <code>/sso/authorize</code> weiter und tauschen den Token per
<code>POST /sso/verify</code> gegen Nutzerdaten samt Discord-Rollen ein.
</p>
{ssoApps.length > 0 && (
<div className="key-list">
{ssoApps.map((a) => (
<div className="key-row" key={a.slug}>
<span className="key-name">{a.name}</span>
<span className="key-scopes">{a.slug}</span>
<span className="key-used" title={a.redirect_prefix}>
{a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'}
</span>
<button className="card-delete" title="App entfernen" onClick={() => removeSsoApp(a.slug)}></button>
</div>
))}
</div>
)}
{createdSso && (
<div className="key-created">
<p>
App <strong>{createdSso.name}</strong> registriert Secret <strong>jetzt kopieren</strong>,
es wird nie wieder angezeigt:
</p>
<code>{createdSso.secret}</code>
</div>
)}
<div className="field">
<label>Neue App <span className="field-hint">Kürzel, Anzeigename und erlaubte Rücksprung-URL(s)</span></label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '9rem' }} placeholder="kanban" value={ssoDraft.slug} onChange={(e) => setSsoDraft({ ...ssoDraft, slug: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Kanban-Board" value={ssoDraft.name} onChange={(e) => setSsoDraft({ ...ssoDraft, name: e.target.value })} />
<input type="text" placeholder="https://kanban.d4rkst3r.de/" value={ssoDraft.redirect_prefix} onChange={(e) => setSsoDraft({ ...ssoDraft, redirect_prefix: e.target.value })} />
<button className="btn" onClick={createSsoApp}>Registrieren</button>
</div>
<p className="field-hint" style={{ marginTop: '.4rem' }}>
Mehrere URLs kommagetrennt. Der Bot leitet nur auf Ziele zurück, die mit
einem dieser Präfixe beginnen.
</p>
</div>
</div>
</>
);
const tabServer = (
<>
<div className="settings-section">
<h2 className="settings-title">// Game-Server</h2>
<p className="section-intro">
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
Spieler-Balken, Map, Adresse) aktualisiert alle 2 Minuten, DiscordGSM-Style.
</p>
<div className="field" data-setting="status_channel_id">
<div className="field-row">
<div style={{ flex: 1 }}>
<label>Status-Kanal</label>
<PickSelect
items={channelItems}
value={form.status_channel_id ?? ''}
onChange={(v) => setForm({ ...form, status_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div style={{ flex: 1 }}>
<label>Alert-Kanal <span className="field-hint">🚨 down / online</span></label>
<PickSelect
items={channelItems}
value={form.server_alert_channel_id ?? ''}
onChange={(v) => setForm({ ...form, server_alert_channel_id: v })}
empty="— keine Alerts —"
/>
</div>
</div>
</div>
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
<button className="btn btn-save" onClick={save}>Kanäle speichern</button>
</div>
{servers.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{servers.map((s) => (
<div className="key-row" key={s.id}>
<span className="key-name">{s.name}</span>
<span className="key-scopes">{s.type}</span>
<span className="key-used" title={s.query_url}>{s.query_url}</span>
<button className="btn btn-mini" onClick={() => setServerDraft({
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
image_url: s.image_url ?? '',
})}>Bearbeiten</button>
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
<GameSelect
presets={GAME_PRESETS}
value={serverDraft.type}
onChange={(type) => setServerDraft({ ...serverDraft, type })}
/>
{serverNeedsUrl ? (
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
) : (
<>
<input type="text" placeholder="Host / IP" value={serverDraft.host} onChange={(e) => setServerDraft({ ...serverDraft, host: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder="Port" value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
</>
)}
</div>
<p className="field-hint" style={{ marginTop: '.3rem' }}>
Neben FiveM/HTTP funktioniert jede <a href="https://github.com/gamedig/node-gamedig#games-list" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Spiel-ID</a> (300+ Spiele) Host + Query-Port eintragen.
</p>
</div>
<div className="field">
<label>Connect-Link + Anzeige-Adresse <span className="field-hint">Link ist im Embed klickbar (auch steam:// / fivem://); Adresse als Copy-Paste-Block</span></label>
<div className="field-row">
<input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
</div>
<label style={{ marginTop: '.6rem' }}>Logo-URL <span className="field-hint">https-Bild (z. B. vom CDN) erscheint auf der /server-Seite und als Embed-Thumbnail</span></label>
<input type="text" placeholder="https://cdn.d4rkst3r.de/logos/eco.png" value={serverDraft.image_url} onChange={(e) => setServerDraft({ ...serverDraft, image_url: e.target.value })} />
<div className="field-row" style={{ marginTop: '.6rem' }}>
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
</div>
</div>
</div>
</>
);
// Filter über dem Modul-Raster — bei 35 Karten ist eine lange Liste
// untereinander mühsam. „Noch einzurichten" ist bewusst dabei: das ist die
// Frage, mit der man am häufigsten herkommt.
const shownModules = modules.filter((m) => {
if (modFilter === 'alle') return true;
if (modFilter === 'todo') return m.enabled && m.missing.length > 0;
if (modFilter === 'aus') return !m.enabled;
return m.group === modFilter;
});
const modFilters = [
{ id: 'alle', label: 'Alle', n: modules.length },
...moduleGroups.map((g) => ({
id: g.id, label: g.label, n: modules.filter((m) => m.group === g.id).length,
})),
{ id: 'todo', label: 'Noch einzurichten', n: modules.filter((m) => m.enabled && m.missing.length > 0).length },
{ id: 'aus', label: 'Ausgeschaltet', n: modules.filter((m) => !m.enabled).length },
];
const groupLabel = (id) => moduleGroups.find((g) => g.id === id)?.label ?? '';
const tabModule = openModule ? (
<ModulePage
id={openModule}
onBack={() => setOpenModule(null)}
onToggle={toggleModule}
form={form}
setForm={setForm}
PickSelect={PickSelect}
channelItems={channelItems}
roleItems={roleItems}
welcomePreviewUrl={welcomePreviewUrl}
flash={flash}
goTab={setTab}
reloadSettings={reloadSettings}
favorites={favorites}
onToggleFavorite={toggleFavorite}
/>
) : (
<div className="settings-section">
<h2 className="settings-title">// Module</h2>
<p className="section-intro">
Jede Funktion des Bots lässt sich hier ein- und ausschalten. Ausgeschaltete
Module reagieren auf nichts mehr keine Posts, keine Befehle, keine
Hintergrund-Prüfungen. Was noch eingerichtet werden muss, steht direkt an
der Karte.
</p>
<div className="mod-filters">
{modFilters.map((f) => (
<button
key={f.id}
className={`mod-filter${modFilter === f.id ? ' active' : ''}`}
disabled={f.n === 0 && modFilter !== f.id}
onClick={() => setModFilter(f.id)}
>
{f.label} <span className="mod-filter-n">{f.n}</span>
</button>
))}
</div>
{shownModules.length === 0 ? (
<p className="notice">Hier ist gerade nichts.</p>
) : (
<div className="mod-grid">
{shownModules.map((m) => {
const Icon = moduleIcon(m.id);
return (
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
<div className="mod-head">
<button
className="mod-open"
title={`${m.name} einrichten`}
onClick={() => setOpenModule(m.id)}
>
<span className="mod-icon"><Icon size={20} /></span>
<span className="mod-titles">
<span className="mod-name">{m.name}</span>
<span className="mod-group">{groupLabel(m.group)}</span>
</span>
</button>
<button
className={`mod-fav${favorites.includes(m.id) ? ' on' : ''}`}
title={favorites.includes(m.id)
? 'Aus der Seitenleiste nehmen'
: 'In die Seitenleiste heften'}
onClick={() => toggleFavorite(m.id)}
>
<IconStar size={15} />
</button>
<button
className={`mod-switch${m.enabled ? ' on' : ''}`}
title={m.enabled ? 'Ausschalten' : 'Einschalten'}
onClick={() => toggleModule(m.id, !m.enabled)}
>
<span className="mod-knob" />
</button>
</div>
<p className="mod-desc">{m.desc}</p>
<div className="mod-foot">
{m.enabled && m.missing.length > 0 && (
<span className="mod-missing with-icon">
<IconWarning size={13} /> Fehlt: {m.missing.map((x) => x.label).join(', ')}
</span>
)}
{m.enabled && m.missing.length === 0 && (
<span className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</span>
)}
{!m.enabled && <span className="mod-off">Ausgeschaltet</span>}
<button className="mod-link" onClick={() => setOpenModule(m.id)}>
Einrichten
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
const tabTexte = (
<div className="settings-section">
<h2 className="settings-title">// Texte</h2>
<p className="section-intro">
Alle Texte am Stück wer nur die einer Funktion sucht, findet sie
auch auf deren Modul-Seite. Zeile anklicken zum Bearbeiten
die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein,
ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
bedeutet: wieder der Standardtext.
</p>
{templateGroups.map((group) => {
const items = templates.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tpl-list">
{items.map((t) => {
const draft = tplDraft[t.id];
const value = draft ?? t.text;
const dirty = draft != null && draft !== t.text;
const off = t.module && modules.some((m) => m.id === t.module && !m.enabled);
const open = openTpl === t.id;
return (
<div
className={`tpl-row${open ? ' open' : ''}${dirty ? ' dirty' : ''}`}
key={t.id}
>
<button
className="tpl-head"
aria-expanded={open}
onClick={() => setOpenTpl(open ? null : t.id)}
>
<span className="tpl-head-main">
<span className="tpl-name">{t.name}</span>
{/* Zusammengeklappt reicht die erste Zeile als Erkennungszeichen */}
<span className="tpl-peek">{value.split('\n')[0]}</span>
</span>
{dirty && <span className="tpl-badge">ungespeichert</span>}
{!dirty && t.customized && <span className="tpl-badge">angepasst</span>}
{off && <span className="tpl-badge muted">Modul aus</span>}
<IconChevronDown className={`tpl-caret${open ? ' up' : ''}`} size={15} />
</button>
{open && (
<div className="tpl-body">
<textarea
id={`tpl-${t.id}`}
className="tpl-input"
rows={t.long ? 5 : 2}
value={value}
spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && (
<div className="tpl-vars">
<span className="tpl-vars-label">Bausteine:</span>
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={`${v.desc} — einfügen`}
onClick={() => insertPlaceholder(t, v.key)}
>
{`{${v.key}}`}
</button>
))}
</div>
)}
<div className="tpl-actions">
<button
className="btn"
disabled={!dirty}
onClick={() => saveTemplate(t.id, value)}
>
Speichern
</button>
{(t.customized || dirty) && (
<button
className="btn btn-ghost with-icon"
onClick={() => saveTemplate(t.id, '')}
title="Standardtext wiederherstellen"
>
<IconRefresh size={13} /> Standard
</button>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
const tuneDirty = tuningValues.some(
(t) => tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value)
);
const tabWerte = (
<div className="settings-section">
<h2 className="settings-title">// Werte</h2>
<p className="section-intro">
Alle Werte am Stück; jeder steht zusätzlich auf der Seite seiner
Funktion. Zahlen, die das Verhalten steuern wie viel XP eine Nachricht bringt,
wie oft geprüft wird, wann gepostet wird. Geänderte Intervalle greifen ab
dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte
Bereich; angepasste Werte lassen sich einzeln zurücksetzen.
</p>
{tuningGroups.map((group) => {
const items = tuningValues.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.6rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tune-list">
{items.map((t) => {
const value = tuneDraft[t.id] ?? t.value;
const dirty = tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value);
return (
<div className={`tune-row${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tune-label">
<span className="tune-name">
{t.label}
{t.customized && <span className="tpl-badge">angepasst</span>}
</span>
{t.hint && <span className="tune-hint">{t.hint}</span>}
</div>
<div className="tune-input">
{t.choices ? (
<select
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
>
{t.choices.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
) : (
<input
type="number"
min={t.min} max={t.max} step={1}
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
/>
)}
<span className="tune-unit">{t.unit}</span>
<span className="tune-range">
{t.choices ? t.choices.join(' · ') : `${t.min}${t.max}`}
{' · Standard '}{t.default}
</span>
{t.customized && (
<button
className="btn-mini"
title="Standard wiederherstellen"
onClick={() => resetTuning(t.id)}
>
Standard
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
<div className="settings-actions">
<button className="btn btn-save" disabled={!tuneDirty} onClick={saveTuning}>
Werte speichern
</button>
</div>
</div>
);
const tabSeiten = (
<div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2>
<p className="section-intro">
Inhalte pflegen, ohne dass jemand Code anfassen muss Regeln, Über uns,
Mitmachen, FAQ. Geschrieben wird in Markdown (wie im Devlog), rechts siehst
du sofort, wie es aussieht.
</p>
{pages.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{pages.map((p) => (
<div className="key-row" key={p.slug}>
<span className="key-name">{p.title}</span>
<span className="key-scopes">/{p.slug}</span>
<span className="key-used">
{p.published ? (p.in_menu ? 'sichtbar · im Menü' : 'sichtbar · nur per Link') : 'Entwurf'}
</span>
<button className="btn btn-mini" onClick={() => setPageDraft({
slug: p.slug, title: p.title, content: p.content,
published: p.published, in_menu: p.in_menu, sort: p.sort ?? 0,
isNew: false,
})}>Bearbeiten</button>
<button className="card-delete" title="Seite löschen" onClick={() => removePage(p.slug)}>
<IconX size={15} />
</button>
</div>
))}
</div>
)}
<div className="field">
<label>{pageDraft.isNew ? 'Neue Seite' : `Bearbeiten: /${pageDraft.slug}`}</label>
<div className="field-row">
<input
type="text" style={{ flex: 'none', width: '14rem' }} placeholder="Titel, z. B. Regeln"
value={pageDraft.title}
onChange={(e) => setPageDraft({
...pageDraft,
title: e.target.value,
// Kürzel nur bei neuen Seiten automatisch mitziehen
slug: pageDraft.isNew ? slugify(e.target.value) : pageDraft.slug,
})}
/>
<input
type="text" style={{ flex: 'none', width: '11rem' }} placeholder="kuerzel"
value={pageDraft.slug}
onChange={(e) => setPageDraft({ ...pageDraft, slug: slugify(e.target.value) })}
/>
<span className="field-hint" style={{ alignSelf: 'center' }}>
Adresse: /{pageDraft.slug || '…'}
</span>
</div>
</div>
<div className="composer-grid">
<div className="field">
<label>Inhalt <span className="field-hint">Markdown: **fett**, *kursiv*, ## Überschrift, - Liste, [Link](url)</span></label>
<textarea
rows={18} placeholder={'## Willkommen\n\nHier stehen unsere Regeln.\n\n- Sei nett\n- Kein Spam'}
value={pageDraft.content}
onChange={(e) => setPageDraft({ ...pageDraft, content: e.target.value })}
/>
</div>
<div className="field">
<label>Vorschau</label>
<div className="card page-preview">
<div className="card-body">
{pageDraft.content.trim()
? <Markdown text={pageDraft.content} />
: <p className="field-hint">Noch kein Inhalt.</p>}
</div>
</div>
</div>
</div>
<div className="field">
<label className="toggle">
<input
type="checkbox" checked={pageDraft.published}
onChange={(e) => setPageDraft({ ...pageDraft, published: e.target.checked })}
/>
<span>Veröffentlicht</span>
<span className="field-hint">aus = Entwurf, nur für dich sichtbar</span>
</label>
<label className="toggle" style={{ marginTop: '.5rem' }}>
<input
type="checkbox" checked={pageDraft.in_menu}
onChange={(e) => setPageDraft({ ...pageDraft, in_menu: e.target.checked })}
/>
<span>Im Community-Menü zeigen</span>
<span className="field-hint">aus = nur über den Direktlink erreichbar</span>
</label>
</div>
<div className="field-row">
<input
type="number" style={{ flex: 'none', width: '7rem' }} placeholder="Position"
value={pageDraft.sort}
onChange={(e) => setPageDraft({ ...pageDraft, sort: e.target.value })}
/>
<button className="btn btn-save" onClick={savePageDraft}>
{pageDraft.isNew ? 'Anlegen' : 'Speichern'}
</button>
{!pageDraft.isNew && (
<button className="btn btn-ghost" onClick={() => setPageDraft(emptyPage)}>Neue Seite</button>
)}
</div>
</div>
);
async function dienstSpeichern() {
const body = { ...dienstDraft };
try {
if (body.id) await apiPut(`/api/monitored/${body.id}`, body);
else await apiPost('/api/monitored', body);
setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
setFeedback('Gespeichert.');
} catch (e) {
setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.');
}
setTimeout(() => setFeedback(null), 3000);
}
async function dienstLoeschen(id, name) {
if (!window.confirm(`\u201e${name}" nicht mehr überwachen?`)) return;
await apiDelete(`/api/monitored/${id}`).catch(() => {});
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
}
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
const tabTeam = (
<>
<div className="settings-section">
<h2 className="settings-title">// Team</h2>
<p className="section-intro">
Team-Mitglieder loggen sich ganz normal mit Discord ein und sehen nur ihre
Bereiche. Brand, System, API-Keys und diese Verwaltung bleiben immer dir
vorbehalten.
</p>
<div className="tm-list">
{owner && (
<div className="tm-row owner">
<Avatar src={owner.avatar} name={owner.name ?? me.user?.username} />
<div className="tm-main">
<span className="tm-name">{owner.name ?? me.user?.username ?? 'Du'}</span>
<span className="tm-scopes"><span className="tm-chip alle">Alle Bereiche</span></span>
</div>
<span className="tm-role">Owner</span>
</div>
)}
{team.map((t) => (
<div className={`tm-row${t.user_id === teamDraft.user_id ? ' aktiv' : ''}`} key={t.user_id}>
<Avatar src={t.avatar} name={t.name ?? t.username} />
<div className="tm-main">
<span className="tm-name">{t.name ?? t.username ?? t.user_id}</span>
<span className="tm-scopes">
{t.scopes.split(',').filter(Boolean).map((sc) => (
<span className="tm-chip" key={sc}>{TEAM_SCOPE_LABELS[sc] ?? sc}</span>
))}
</span>
</div>
<button
className="btn btn-mini"
onClick={() => setTeamDraft({
user_id: t.user_id,
scopes: t.scopes.split(',').filter(Boolean),
})}
>
Rechte
</button>
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}></button>
</div>
))}
{team.length === 0 && (
<div className="tm-row leer">Außer dir hat noch niemand Zugriff.</div>
)}
</div>
<h3 className="tm-form-title">
{teamBearbeitet ? 'Rechte ändern' : 'Jemanden hinzufügen'}
</h3>
<div className="field">
<label>
Discord-User-ID
<span className="field-hint">Rechtsklick auf den User Benutzer-ID kopieren"</span>
</label>
<input
type="text" placeholder="z. B. 123456789012345678"
value={teamDraft.user_id}
onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })}
/>
</div>
<div className="field">
<label>Bereiche <span className="field-hint">mindestens einer</span></label>
<div className="tm-scopes-pick">
{Object.entries(TEAM_SCOPE_LABELS).map(([sid, label]) => {
const an = teamDraft.scopes.includes(sid);
return (
<button
type="button" key={sid}
className={`tm-scope${an ? ' on' : ''}`}
onClick={() => setTeamDraft({
...teamDraft,
scopes: an
? teamDraft.scopes.filter((x) => x !== sid)
: [...teamDraft.scopes, sid],
})}
>
<span className="tm-scope-mark">{an ? '✓' : ''}</span>
<span className="tm-scope-text">
<span className="tm-scope-id">{sid}</span>
<span className="tm-scope-desc">{label}</span>
</span>
</button>
);
})}
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={saveTeam}>
{teamBearbeitet ? 'Rechte speichern' : 'Hinzufügen'}
</button>
{(teamDraft.user_id || teamDraft.scopes.length > 0) && (
<button className="btn btn-ghost" onClick={() => setTeamDraft({ user_id: '', scopes: [] })}>
Abbrechen
</button>
)}
</div>
</div>
{audit.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Protokoll</h2>
<p className="section-intro">
Wer hat was im Webinterface geändert.
</p>
<div className="tm-list">
{auditSichtbar.map((a) => (
<div className="tm-row audit" key={a.id}>
<Avatar src={a.avatar} name={a.username} klein />
<span className="tm-audit-name">{a.username}</span>
<span className="tm-audit-action">{a.action}</span>
<span className="tm-audit-detail" title={a.detail}>{a.detail}</span>
<span className="tm-audit-time" title={a.ts.replace('T', ' ')}>{vorWieLange(a.ts)}</span>
</div>
))}
</div>
{audit.length > 12 && (
<div className="settings-actions" style={{ marginTop: '.8rem' }}>
<button className="btn btn-ghost" onClick={() => setAuditAlle(!auditAlle)}>
{auditAlle ? 'Weniger zeigen' : `Alle ${audit.length} zeigen`}
</button>
</div>
)}
</div>
)}
</>
);
const content = {
status: tabStatus,
module: tabModule,
texte: tabTexte,
werte: tabWerte,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
rollen: tabRollen,
support: tabSupport,
bewerbungen: tabBewerbungen,
composer: tabComposer,
seiten: tabSeiten,
server: tabServer,
system: tabSystem,
api: tabApi,
team: tabTeam,
}[tab];
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
return (
<>
{header}
<section className="content content-wide">
<div className="settings-layout">
<nav className="settings-nav">
<div className="settings-search">
<IconSearch size={15} />
<input
type="text"
value={tabSearch}
placeholder="Suchen "
aria-label="Bereich suchen"
onChange={(e) => setTabSearch(e.target.value)}
onKeyDown={(e) => {
// Einstellungen zuerst: wer „schwellwert" tippt,
// will das Feld, nicht den Bereich
if (e.key === 'Enter') {
if (foundSettings[0]) jumpToSetting(foundSettings[0]);
else if (foundTabs[0]) { setTab(foundTabs[0].id); setTabSearch(''); }
}
if (e.key === 'Escape') setTabSearch('');
}}
/>
{tabSearch && (
<button className="settings-search-clear" aria-label="Suche leeren" onClick={() => setTabSearch('')}>
<IconX size={13} />
</button>
)}
</div>
{foundSettings.length > 0 && (
<div className="settings-nav-group">
<span className="settings-nav-label">Einstellungen</span>
{foundSettings.map((s) => (
<button
key={s.id}
className="settings-hit"
onClick={() => jumpToSetting(s)}
>
<span className="settings-hit-label">{s.label}</span>
<span className="settings-hit-tab">
{TABS.find((t) => t.id === s.tab)?.label ?? s.tab}
</span>
</button>
))}
</div>
)}
{foundTabs.length === 0 && foundSettings.length === 0 && (
<p className="settings-nav-empty">Nichts gefunden.</p>
)}
{/* Angeheftete Funktionen — direkt erreichbar, ohne Umweg
übers Raster. Bei aktiver Suche ausgeblendet, dort
zählen Treffer. */}
{!query && canTab('module') && favorites.length > 0 && (
<div className="settings-nav-group">
<span className="settings-nav-label">Meine Funktionen</span>
{favorites.map((id) => {
const m = modules.find((x) => x.id === id);
if (!m) return null;
const Icon = moduleIcon(id);
return (
<button
key={id}
className={`settings-nav-item ${openModule === id ? 'active' : ''}`}
onClick={() => { setTabState('module'); setOpenModule(id); }}
>
<span className="settings-nav-icon"><Icon /></span>
{m.name}
</button>
);
})}
</div>
)}
{TAB_SECTIONS.map((section) => {
const items = foundTabs.filter((t) => t.section === section.id);
if (items.length === 0) return null;
return (
<div className="settings-nav-group" key={section.id}>
<span className="settings-nav-label">{section.label}</span>
{items.map((t) => (
<button
key={t.id}
className={`settings-nav-item ${tab === t.id && !(t.id === 'module' && openModule) ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
<span className="settings-nav-icon"><t.Icon /></span>
{t.label}
</button>
))}
</div>
);
})}
</nav>
<div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
{content}
{/* Platz, damit die Speichern-Leiste nichts verdeckt */}
{dirtyCount > 0 && <div style={{ height: '4.5rem' }} />}
</div>
</div>
{/* Erscheint erst, wenn wirklich etwas offen ist — vorher stand der
Knopf am Seitenende und war bei langen Bereichen nicht zu sehen. */}
{dirtyCount > 0 && (
<div className="save-bar" role="status">
<span className="save-bar-text">
{dirtyCount === 1 ? '1 Änderung offen' : `${dirtyCount} Änderungen offen`}
</span>
<button className="btn btn-ghost" onClick={discardChanges}>Verwerfen</button>
<button className="btn btn-save" onClick={saveAll}>Speichern</button>
</div>
)}
{feedback && <div className="feedback-float">{feedback}</div>}
</section>
</>
);
}