Files
d4rkbot/frontend/src/pages/Settings.jsx
T
D4rkst3randClaude Fable 5 2ce274707a
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Config statt Setup: Bereiche gruppiert, Suche, Status als Einstieg
Die Seite hieß Setup und listete 15 Bereiche flach untereinander. Wer
nicht wusste, dass Geburtstage unter „Community" stecken und Willkommens-
Texte unter „Support", hat geklickt bis er es fand.

Die Seitenleiste gruppiert jetzt nach Überblick, Auftritt, Inhalte,
Community, Technik und Zugang. Darüber steht ein Suchfeld, das nicht nur
Beschriftungen durchsucht, sondern auch Stichwörter je Bereich — „geburtstag"
führt zu Community, „ticket" zu Support. Enter springt zum ersten Treffer.

Der aktive Bereich steht in der Adresse (/settings#texte). Damit überlebt er
das Neuladen, und man kann jemandem einen Link auf genau die Stelle schicken.

Der Status-Bereich war die leerste Seite im ganzen Panel: sechs Zahlen. Er ist
jetzt der Einstieg und beantwortet die Frage, die man beim Öffnen wirklich hat
— was läuft noch nicht? Eingeschaltete Module, denen ein Kanal oder eine Rolle
fehlt, stehen dort mit einem Knopf, der direkt an die richtige Stelle springt.
Ist alles eingerichtet, sagt die Seite genau das.

Umbenannt in „Config", weil „Setup" nach einmaliger Einrichtung klingt — die
Seite ist aber der Ort, an dem man dauerhaft alles einstellt.

Schmale Bildschirme: die Leiste wird zur umbrechenden Zeile ohne
Gruppen-Überschriften, das Suchfeld nimmt die volle Breite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:13:33 +02:00

2246 lines
121 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,
} from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
const API_SCOPES = [
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
{ id: 'dm', label: 'dm', hint: 'Direktnachrichten senden' },
{ id: 'roles', label: 'roles', hint: 'Rollen geben/nehmen' },
{ id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' },
];
// Bereiche der Seitenleiste — bei 15 Tabs findet man ohne Gruppierung nichts mehr.
const TAB_SECTIONS = [
{ id: 'ueberblick', label: 'Überblick' },
{ id: 'auftritt', label: 'Auftritt' },
{ id: 'inhalte', label: 'Inhalte' },
{ id: 'community', label: 'Community' },
{ id: 'technik', label: 'Technik' },
{ id: 'zugang', label: 'Zugang' },
];
// `find` sind zusätzliche Suchbegriffe: was jemand tippt, wenn er den Tab-Namen
// nicht kennt („geburtstag" → Community, „willkommen" → Support).
const TABS = [
{ id: 'status', Icon: IconStatus, label: 'Status', section: 'ueberblick',
find: 'uptime laufzeit datenbank backup version zahlen übersicht' },
{ id: 'module', Icon: IconLayers, label: 'Module', section: 'ueberblick',
find: 'funktionen an aus schalter aktivieren deaktivieren' },
{ id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick',
find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' },
{ id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt',
find: 'avatar banner farbe logo name footer aussehen embed' },
{ id: 'seiten', Icon: IconPages, label: 'Seiten', section: 'auftritt',
find: 'regeln über uns faq markdown menü impressum' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds', section: 'inhalte',
find: 'devlog commit release changelog rückblick threads ping' },
{ id: 'composer', Icon: IconComposer, label: 'Composer', section: 'inhalte',
find: 'nachricht senden embed bauen tags trigger geplant zeitplan' },
{ id: 'community', Icon: IconCommunity, label: 'Community', section: 'community',
find: 'level xp starboard galerie geburtstag playtester alpha keys voting wünsche autorolle' },
{ id: 'rollen', Icon: IconRoles, label: 'Rollen', section: 'community',
find: 'rollen menü selbstbedienung buttons reaction roles' },
{ id: 'support', Icon: IconSupport, label: 'Support', section: 'community',
find: 'ticket modmail willkommen protokoll modlog moderation warnung' },
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen', section: 'community',
find: 'formular fragen bewerbung prüfen annehmen ablehnen' },
{ id: 'server', Icon: IconServer, label: 'Server', section: 'technik',
find: 'game server monitor fivem minecraft status spieler gamedig' },
{ id: 'system', Icon: IconSystem, label: 'System', section: 'technik',
find: 'backup gitea watchdog twitch youtube events sprachkanäle impressum datenschutz' },
{ id: 'api', Icon: IconApi, label: 'API', section: 'zugang',
find: 'schlüssel key token zugriff sso webhook' },
{ id: 'team', Icon: IconTeam, label: 'Team', section: 'zugang',
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
];
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
const TAB_SCOPES = {
status: 'any',
module: 'settings',
texte: 'settings',
brand: null,
feeds: 'settings',
community: 'community',
rollen: 'rollen',
support: 'settings',
bewerbungen: 'bewerbungen',
composer: 'content',
seiten: 'content',
server: 'server',
system: null,
api: null,
team: null,
};
const TEAM_SCOPE_LABELS = {
content: 'Composer, Tags, Triggers & geplante Posts',
community: 'Playtester & Alpha-Keys',
rollen: 'Rollen-Menüs',
bewerbungen: 'Bewerbungen',
server: 'Game-Server',
settings: 'Kanäle & Feed-Einstellungen',
devlogs: 'Devlogs löschen',
};
/** Sekunden → "2d 4h 13m" */
/** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon.
* Filtert die Presets beim Tippen, freie gamedig-IDs bleiben erlaubt. */
function GameSelect({ presets, value, onChange }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const q = value.trim().toLowerCase();
const filtered = presets.filter(([id, label]) =>
!q || id.includes(q) || label.toLowerCase().includes(q)
);
return (
<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>
);
}
function fmtUptime(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
}
function fmtBytes(b) {
if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
return `${(b / 1024).toFixed(0)} KB`;
}
export default function Settings({ me }) {
// Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten
const [tab, setTabState] = useState(
() => TABS.find((t) => t.id === window.location.hash.slice(1))?.id ?? 'status'
);
const setTab = (id) => {
setTabState(id);
window.history.replaceState(null, '', `#${id}`);
};
const [tabSearch, setTabSearch] = useState('');
const [data, setData] = useState(null);
const [form, setForm] = useState(null);
const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null);
// Community
const [playtesters, setPlaytesters] = useState([]);
// Rollen-Menüs
const emptyMenu = {
id: null, title: '', description: '', channel_id: '', exclusive: false,
entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
};
const [menus, setMenus] = useState([]);
const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Branding
const [giteaToken, setGiteaToken] = useState('');
const [botName, setBotName] = useState('');
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
// Alpha-Keys
const [alphaKeys, setAlphaKeys] = useState(null);
const [keyUpload, setKeyUpload] = useState('');
// Bewerbungs-Formulare
const emptyForm = {
id: null, title: '', description: '', review_channel_id: '',
approve_role_id: '', post_channel_id: '', questions: [''],
};
const [appForms, setAppForms] = useState([]);
const [formDraft, setFormDraft] = useState(emptyForm);
// Triggers
const [triggers, setTriggers] = useState([]);
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
// Game-Server
const GAME_PRESETS = [
['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'],
['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'],
['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'],
['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'],
['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'],
['garrysmod', '🔧 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([]);
// Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
// Text-Vorlagen (was der Bot nach außen schreibt)
const [templates, setTemplates] = useState([]);
const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
const [pageDraft, setPageDraft] = useState(emptyPage);
// Portal-Dienste (Kacheln auf der Startseite)
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
const [services, setServices] = useState([]);
const [serviceDraft, setServiceDraft] = useState(emptyService);
// SSO-Apps (Fremd-Dienste mit Discord-Login)
const [ssoApps, setSsoApps] = useState([]);
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
const [createdSso, setCreatedSso] = useState(null);
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
const canTab = (id) => {
if (me.admin) return true;
const need = TAB_SCOPES[id];
if (need === 'any') return (me.scopes?.length ?? 0) > 0;
return need ? me.scopes?.includes(need) : false;
};
const visibleTabs = TABS.filter((t) => canTab(t.id));
// Suche greift auf Beschriftung und Stichwörter — wer „geburtstag" tippt,
// landet bei Community, ohne den Tab-Namen zu kennen.
const query = tabSearch.trim().toLowerCase();
const foundTabs = query
? visibleTabs.filter((t) => `${t.label} ${t.find}`.toLowerCase().includes(query))
: visibleTabs;
// Tags + geplante Posts
const [tags, setTags] = useState([]);
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
const [scheduled, setScheduled] = useState([]);
const [schedDraft, setSchedDraft] = useState({
channel_id: '', embed_title: '', content: '', type: 'once',
date: '', time: '18:00', weekday: '5',
});
// API-Keys
const [keys, setKeys] = useState([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState(['message']);
const [createdKey, setCreatedKey] = useState(null);
useEffect(() => {
if (!me.admin && !(me.scopes?.length > 0)) return;
apiGet('/api/settings')
.then((d) => {
setData(d);
setForm(d.settings);
})
.catch((e) => setError(e.status));
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
apiGet('/api/modules').then((d) => {
setModules(d.modules);
setModuleGroups(d.groups);
}).catch(() => {});
apiGet('/api/texts').then((d) => {
setTemplates(d.templates);
setTemplateGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
if (data?.status?.botName) setBotName(data.status.botName);
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
}, [data]);
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
useEffect(() => {
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
}, [me.admin, me.scopes]);
async function saveTeam() {
if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) {
flash('✗ User-ID und mindestens ein Bereich nötig');
return;
}
try {
const res = await apiPut('/api/webadmins', teamDraft);
setTeam(res.admins);
setTeamDraft({ user_id: '', scopes: [] });
flash('✓ Team-Mitglied gespeichert');
} catch {
flash('✗ Fehlgeschlagen — User-ID prüfen');
}
}
async function removeTeam(userId) {
if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return;
const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null);
if (res) setTeam(res.admins);
}
async function toggleModule(id, enabled) {
// Sofort umschalten, damit sich der Schalter nicht träge anfühlt
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled } : m)));
try {
const res = await apiPut(`/api/modules/${id}`, { enabled });
setModules(res.modules);
} catch {
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled: !enabled } : m)));
flash('✗ Umschalten fehlgeschlagen');
}
}
/** Vorlage speichern. Leerer Text stellt den Standard wieder her. */
async function saveTemplate(id, text) {
try {
const res = await apiPut(`/api/texts/${encodeURIComponent(id)}`, { text });
setTemplates(res.templates);
setTplDraft(({ [id]: _drop, ...rest }) => rest);
flash(text.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
/** Platzhalter an der Cursor-Position einfügen */
function insertPlaceholder(tpl, key) {
const field = document.getElementById(`tpl-${tpl.id}`);
const current = tplDraft[tpl.id] ?? tpl.text;
const at = field?.selectionStart ?? current.length;
const next = `${current.slice(0, at)}{${key}}${current.slice(field?.selectionEnd ?? at)}`;
setTplDraft((old) => ({ ...old, [tpl.id]: next }));
// Cursor hinter den eingefügten Platzhalter setzen
requestAnimationFrame(() => {
if (!field) return;
field.focus();
const pos = at + key.length + 2;
field.setSelectionRange(pos, pos);
});
}
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) {
return String(value).toLowerCase()
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
.slice(0, 40);
}
async function savePageDraft() {
if (!pageDraft.title.trim() || !pageDraft.slug) {
flash('✗ Titel und Kürzel nötig');
return;
}
try {
const res = await apiPut(`/api/pages/${pageDraft.slug}`, pageDraft);
setPages(res.pages.map((p) => ({
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
})));
setPageDraft({ ...pageDraft, isNew: false });
flash('✓ Seite gespeichert');
} catch (e) {
flash(e.status === 400 ? '✗ Kürzel ist reserviert oder ungültig' : '✗ Speichern fehlgeschlagen');
}
}
async function removePage(slug) {
if (!window.confirm(`Seite „/${slug}" wirklich löschen?`)) return;
const res = await apiDelete(`/api/pages/${slug}`).catch(() => null);
if (res) {
setPages(res.pages.map((p) => ({
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
})));
if (pageDraft.slug === slug) setPageDraft(emptyPage);
}
}
async function saveService() {
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
flash('✗ Name und URL nötig');
return;
}
try {
const res = serviceDraft.id
? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft)
: await apiPost('/api/services', serviceDraft);
setServices(res.services);
setServiceDraft(emptyService);
flash('✓ Dienst gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen (URL prüfen)');
}
}
async function removeService(id) {
const res = await apiDelete(`/api/services/${id}`).catch(() => null);
if (res) setServices(res.services);
}
async function createSsoApp() {
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
return;
}
try {
const res = await apiPost('/api/ssoapps', ssoDraft);
setCreatedSso(res);
setSsoDraft({ slug: '', name: '', redirect_prefix: '' });
setSsoApps((await apiGet('/api/ssoapps')).apps);
flash('✓ App registriert');
} catch (e) {
flash(e.status === 409 ? '✗ Kürzel schon vergeben' : '✗ Registrierung fehlgeschlagen');
}
}
async function removeSsoApp(slug) {
if (!window.confirm(`App „${slug}" entfernen? Der Login über diese App funktioniert danach nicht mehr.`)) return;
const res = await apiDelete(`/api/ssoapps/${slug}`).catch(() => null);
if (res) setSsoApps(res.apps);
}
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
async function saveServer() {
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig');
return;
}
try {
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
else await apiPost('/api/gameservers', serverDraft);
setServers((await apiGet('/api/gameservers')).servers);
setServerDraft(emptyServer);
flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick');
} catch {
flash('✗ Speichern fehlgeschlagen (URL mit http/https?)');
}
}
async function removeServer(id) {
if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return;
await apiDelete(`/api/gameservers/${id}`).catch(() => {});
setServers((await apiGet('/api/gameservers')).servers);
if (serverDraft.id === id) setServerDraft(emptyServer);
}
async function uploadAlphaKeys() {
if (!keyUpload.trim()) return;
try {
const res = await apiPost('/api/alphakeys', { keys: keyUpload });
setAlphaKeys(res);
setKeyUpload('');
flash(`✓ ${res.added} Keys hinzugefügt`);
} catch {
flash('✗ Upload fehlgeschlagen');
}
}
async function distributeKeys() {
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
try {
const res = await apiPost('/api/alphakeys/distribute');
setAlphaKeys(res);
flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`);
} catch {
flash('✗ Verteilung fehlgeschlagen');
}
}
async function saveForm(publish = false) {
const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean);
if (!formDraft.title.trim() || questions.length === 0) {
flash('✗ Titel und mindestens eine Frage nötig');
return;
}
try {
const body = { ...formDraft, questions };
const res = formDraft.id
? await apiPut(`/api/appforms/${formDraft.id}`, body)
: await apiPost('/api/appforms', body);
if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`);
setAppForms((await apiGet('/api/appforms')).forms);
setFormDraft(emptyForm);
flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert');
} catch {
flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen');
}
}
async function removeForm(id) {
if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return;
await apiDelete(`/api/appforms/${id}`).catch(() => {});
setAppForms((await apiGet('/api/appforms')).forms);
if (formDraft.id === id) setFormDraft(emptyForm);
}
async function saveTriggerDraft() {
if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) {
flash('✗ Keyword (min 3 Zeichen) und Antwort nötig');
return;
}
try {
await apiPut('/api/triggers', triggerDraft);
setTriggers((await apiGet('/api/triggers')).triggers);
setTriggerDraft({ keyword: '', reply: '' });
flash('✓ Trigger gespeichert');
} catch {
flash('✗ Trigger fehlgeschlagen');
}
}
async function removeTrigger(keyword) {
await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {});
setTriggers((await apiGet('/api/triggers')).triggers);
}
async function uploadImage(target, file) {
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await apiPost(`/api/branding/${target}`, { data: reader.result });
flash(`✓ ${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`);
} catch {
flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)');
}
};
reader.readAsDataURL(file);
}
async function saveBrand() {
try {
const body = { ...form, bot_name: botName, bot_description: botDesc };
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
setGiteaToken('');
setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert & angewendet');
} catch {
flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)');
}
}
async function saveTagDraft() {
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
flash('✗ Name und Inhalt nötig');
return;
}
try {
await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content });
setTags((await apiGet('/api/tags')).tags);
setTagDraft({ name: '', content: '' });
flash('✓ Tag gespeichert');
} catch {
flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)');
}
}
async function removeTag(name) {
if (!window.confirm(`Tag „${name}" löschen?`)) return;
await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {});
setTags((await apiGet('/api/tags')).tags);
}
async function createSchedule() {
if (!schedDraft.channel_id || !schedDraft.content.trim()) {
flash('✗ Kanal und Text nötig');
return;
}
try {
await apiPost('/api/scheduled', schedDraft);
setScheduled((await apiGet('/api/scheduled')).posts);
setSchedDraft({ ...schedDraft, content: '', embed_title: '' });
flash('✓ Post geplant');
} catch {
flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen');
}
}
async function removeSchedule(id) {
await apiDelete(`/api/scheduled/${id}`).catch(() => {});
setScheduled((await apiGet('/api/scheduled')).posts);
}
function flash(msg) {
setFeedback(msg);
setTimeout(() => setFeedback(null), 3000);
}
async function save() {
try {
const body = { ...form };
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
async function sendTest(target) {
try {
await apiPost(`/api/settings/test/${target}`);
flash('✓ Test-Nachricht gesendet');
} catch {
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
}
}
async function createKey() {
if (!newKeyName.trim() || newKeyScopes.length === 0) return;
try {
const res = await apiPost('/api/apikeys', { name: newKeyName.trim(), scopes: newKeyScopes });
setCreatedKey(res);
setNewKeyName('');
setKeys((await apiGet('/api/apikeys')).keys);
} catch {
flash('✗ Key-Erstellung fehlgeschlagen');
}
}
async function revokeKey(id) {
if (!window.confirm('Diesen API-Key widerrufen? Skripte mit diesem Key verlieren sofort den Zugriff.')) return;
await apiDelete(`/api/apikeys/${id}`).catch(() => {});
setKeys((await apiGet('/api/apikeys')).keys);
if (createdKey?.id === id) setCreatedKey(null);
}
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
async function saveMenu(publish = false) {
const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id);
if (!menuDraft.title.trim() || entries.length === 0) {
flash('✗ Titel und mindestens eine Rolle nötig');
return;
}
if (publish && !menuDraft.channel_id) {
flash('✗ Zum Posten einen Kanal wählen');
return;
}
try {
const body = { ...menuDraft, entries };
const res = menuDraft.id
? await apiPut(`/api/rolemenus/${menuDraft.id}`, body)
: await apiPost('/api/rolemenus', body);
let menu = res.menu;
if (publish) {
await apiPost(`/api/rolemenus/${menu.id}/publish`);
flash('✓ Menü in Discord gepostet');
} else {
flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert');
}
await reloadMenus();
setMenuDraft(emptyMenu);
} catch {
flash('✗ Speichern fehlgeschlagen — Einträge prüfen');
}
}
async function removeMenu(id) {
if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return;
await apiDelete(`/api/rolemenus/${id}`).catch(() => {});
if (menuDraft.id === id) setMenuDraft(emptyMenu);
await reloadMenus();
}
const editMenu = (m) =>
setMenuDraft({
id: m.id, title: m.title, description: m.description,
channel_id: m.channel_id ?? '', exclusive: m.exclusive,
entries: m.entries.length
? m.entries.map((e) => ({ style: 'secondary', ...e }))
: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
});
const setMenuEntry = (i, patch) =>
setMenuDraft({
...menuDraft,
entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)),
});
const header = (
<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 channelOptions = data.channels.map((c) => (
<option key={c.id} value={c.id}>#{c.name} {c.guild}</option>
));
const roleOptions = (data.roles ?? []).map((r) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</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);
const tabStatus = (
<>
<div className="settings-section">
<h2 className="settings-title">// Status</h2>
<div className="stats">
<div className="stat"><span className="stat-value">{data.status.botTag ?? '—'}</span><span className="stat-label">Bot</span></div>
<div className="stat"><span className="stat-value">{fmtUptime(data.status.uptimeSeconds)}</span><span className="stat-label">Uptime</span></div>
{modules.length > 0 && (
<div className="stat">
<span className="stat-value">{activeModules.length}<span className="stat-of">/{modules.length}</span></span>
<span className="stat-label">Module an</span>
</div>
)}
<div className="stat"><span className="stat-value">{data.status.devlogs}</span><span className="stat-label">Devlogs</span></div>
<div className="stat"><span className="stat-value">{data.status.commits}</span><span className="stat-label">Commits</span></div>
<div className="stat"><span className="stat-value">{fmtBytes(data.status.dbSizeBytes)}</span><span className="stat-label">Datenbank</span></div>
<div className="stat">
<span className="stat-value">{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}</span>
<span className="stat-label">Letztes Backup</span>
</div>
</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.
</p>
<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.join(', ')}
</span>
{m.tab && (
<button className="btn-mini" onClick={() => setTab(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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label>
<div className="field-row">
<select value={form.devlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
</div>
</div>
<div className="field">
<label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label>
<div className="field-row">
<select value={form.commit_channel_id ?? ''} onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('commit')}>Test</button>
</div>
</div>
<div className="field">
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases leer = aus</span></label>
<div className="field-row">
<select value={form.release_channel_id ?? ''} onChange={(e) => setForm({ ...form, release_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('release')}>Test</button>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Devlog</h2>
<div className="field">
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post leer = kein Ping</span></label>
<select value={form.devlog_ping_role_id ?? ''} onChange={(e) => setForm({ ...form, devlog_ping_role_id: e.target.value })}>
<option value=""> kein Ping </option>
{roleOptions}
</select>
</div>
<div className="field">
<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">
<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">
<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">
<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">
<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">
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
<select value={form.birthday_channel_id ?? ''} onChange={(e) => setForm({ ...form, birthday_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
<select value={form.birthday_role_id ?? ''} onChange={(e) => setForm({ ...form, birthday_role_id: e.target.value })}>
<option value="">— keine Rolle —</option>
{roleOptions}
</select>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Playtester</h2>
<div className="field">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<select value={form.playtester_role_id ?? ''} onChange={(e) => setForm({ ...form, playtester_role_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{roleOptions}
</select>
</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">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
<div className="field-row">
<select value={form.starboard_channel_id ?? ''} onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<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">
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label>
<select value={form.screenshot_channel_id ?? ''} onChange={(e) => setForm({ ...form, screenshot_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label>
<select value={form.voting_channel_id ?? ''} onChange={(e) => setForm({ ...form, voting_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Level-System</h2>
<div className="field">
<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">
<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">
<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">
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch leer = aus</span></label>
<select value={form.autorole_id ?? ''} onChange={(e) => setForm({ ...form, autorole_id: e.target.value })}>
<option value=""> deaktiviert </option>
{roleOptions}
</select>
</div>
<div className="field">
<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 })} />
<select value={menuDraft.channel_id} onChange={(e) => setMenuDraft({ ...menuDraft, channel_id: e.target.value })}>
<option value=""> Kanal wählen </option>
{channelOptions}
</select>
</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 })} />
<select value={entry.role_id} onChange={(e) => setMenuEntry(i, { role_id: e.target.value })}>
<option value="" disabled> Rolle </option>
{roleOptions}
</select>
<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>
</>
);
const tabSupport = (
<div className="settings-section">
<h2 className="settings-title">// Moderation &amp; Kontakt</h2>
<div className="field">
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal DMs an den Bot landen hier als Threads</span></label>
<select value={form.modmail_channel_id ?? ''} onChange={(e) => setForm({ ...form, modmail_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label>
<select value={form.ticket_channel_id ?? ''} onChange={(e) => setForm({ ...form, ticket_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
<select value={form.welcome_channel_id ?? ''} onChange={(e) => setForm({ ...form, welcome_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT gelöschte/bearbeitete Nachrichten</span></label>
<select value={form.modlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, modlog_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</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">
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
<option value=""> Post-Kanal (öffentlich) </option>
{channelOptions}
</select>
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
<option value=""> Review-Kanal (Staff) </option>
{channelOptions}
</select>
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
<option value=""> Rolle bei Annahme </option>
{roleOptions}
</select>
</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">
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<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">
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
<select value={form.events_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, events_announce_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
<select value={form.social_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, social_announce_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div className="field">
<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">
<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>
<select value={form.tempvoice_channel_id ?? ''} onChange={(e) => setForm({ ...form, tempvoice_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Backups &amp; Watchdog</h2>
<div className="field">
<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">
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
<div className="field-row">
<select value={form.backup_channel_id ?? ''} onChange={(e) => setForm({ ...form, backup_channel_id: e.target.value })}>
<option value="">— nur lokal —</option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
</div>
</div>
<div className="field">
<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">
<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>
</>
);
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">
<div className="field-row">
<div style={{ flex: 1 }}>
<label>Status-Kanal</label>
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
<div style={{ flex: 1 }}>
<label>Alert-Kanal <span className="field-hint">🚨 down / online</span></label>
<select value={form.server_alert_channel_id ?? ''} onChange={(e) => setForm({ ...form, server_alert_channel_id: e.target.value })}>
<option value=""> keine Alerts </option>
{channelOptions}
</select>
</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>
</>
);
const tabModule = (
<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>
{moduleGroups.map((group) => {
const mods = modules.filter((m) => m.group === group.id);
if (mods.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="mod-grid">
{mods.map((m) => (
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
<div className="mod-head">
<span className="mod-name">{m.name}</span>
<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>
{m.enabled && m.missing.length > 0 && (
<p className="mod-missing with-icon">
<IconWarning size={13} /> Fehlt noch: {m.missing.join(', ')}
</p>
)}
{m.enabled && m.missing.length === 0 && (
<p className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</p>
)}
{m.tab && (
<button className="mod-link" onClick={() => setTab(m.tab)}>
Einstellungen
</button>
)}
</div>
))}
</div>
</div>
);
})}
</div>
);
const tabTexte = (
<div className="settings-section">
<h2 className="settings-title">// Texte</h2>
<p className="section-intro">
Alles, was der Bot nach außen schreibt Begrüßungen, Bestätigungen,
Direktnachrichten. Die Bausteine in geschweiften Klammern setzt der Bot
beim Senden ein; klick einen an, um ihn an der Cursor-Position einzufügen.
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);
return (
<div className={`card tpl-card${t.long ? ' long' : ''}${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tpl-head">
<span className="tpl-name">{t.name}</span>
{t.customized && <span className="tpl-badge">angepasst</span>}
{off && <span className="tpl-badge muted">Modul aus</span>}
</div>
<textarea
id={`tpl-${t.id}`}
className="tpl-input"
rows={t.long ? 4 : 2}
value={value}
spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && (
<div className="tpl-vars">
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={v.desc}
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>
);
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>
);
const tabTeam = (
<div className="settings-section">
<h2 className="settings-title">// Team</h2>
<p className="section-intro">
Gib Team-Mitgliedern gezielten Zugriff aufs Webinterface sie loggen sich ganz
normal mit Discord ein und sehen nur ihre Bereiche. Brand, System, API-Keys
und diese Team-Verwaltung bleiben immer dir vorbehalten.
</p>
{team.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{team.map((t) => (
<div className="key-row" key={t.user_id}>
<span className="key-name">{t.username ?? t.user_id}</span>
<span className="key-scopes">{t.scopes}</span>
<button className="btn btn-mini" onClick={() => setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten</button>
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>Discord-User-ID <span className="field-hint">Rechtsklick auf den User Benutzer-ID kopieren"</span></label>
<div className="field-row">
<input type="text" placeholder="z. B. 123456789012345678" value={teamDraft.user_id} onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })} />
<button className="btn btn-save" onClick={saveTeam}>Speichern</button>
</div>
</div>
<div className="field">
<label>Bereiche</label>
<div className="scope-row" style={{ flexDirection: 'column', gap: '.5rem', alignItems: 'flex-start' }}>
{Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => (
<label className="scope-pick" key={id}>
<input
type="checkbox"
checked={teamDraft.scopes.includes(id)}
onChange={(e) =>
setTeamDraft({
...teamDraft,
scopes: e.target.checked
? [...teamDraft.scopes, id]
: teamDraft.scopes.filter((s) => s !== id),
})
}
/>
{id} — {label}
</label>
))}
</div>
</div>
{audit.length > 0 && (
<>
<h2 className="settings-title" style={{ marginTop: '2rem' }}>// Audit-Log</h2>
<p className="field-hint" style={{ marginBottom: '.8rem' }}>
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
</p>
<div className="key-list audit-list">
{audit.map((a) => (
<div className="key-row" key={a.id}>
<span className="audit-ts">{a.ts.slice(0, 16).replace('T', ' ')}</span>
<span className="key-name">{a.username}</span>
<span className="audit-action">{a.action}</span>
<span className="key-used audit-detail" title={a.detail}>{a.detail}</span>
</div>
))}
</div>
</>
)}
</div>
);
const content = {
status: tabStatus,
module: tabModule,
texte: tabTexte,
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
const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
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) => {
if (e.key === 'Enter' && 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>
{foundTabs.length === 0 && (
<p className="settings-nav-empty">Nichts gefunden.</p>
)}
{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 ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
<span className="settings-nav-icon"><t.Icon /></span>
{t.label}
</button>
))}
</div>
);
})}
</nav>
<div className="settings-content">
{content}
{showSave && (
<div className="settings-actions">
<button className="btn btn-save" onClick={save}>Speichern</button>
</div>
)}
</div>
</div>
{feedback && <div className="feedback-float">{feedback}</div>}
</section>
</>
);
}