Files
d4rkbot/frontend/src/pages/Settings.jsx
T
D4rkst3randClaude Fable 5 aa12dff7ab Member-Bereich: Profilseite, Rollen-Selfservice, Web-Voting, Member-Gate
Der Discord-Login ist jetzt für alle Member nützlich:
- /profil: Rang + XP-Fortschritt, Discord-Rollen als Chips, Playtester-
  Badge, eigener Alpha-Key mit Kopier-Button
- Rollen-Selfservice: veröffentlichte Rollen-Menüs direkt im Browser
  togglen (gleiche Exklusiv-Logik wie die Discord-Buttons; nur Rollen
  aus aktiven Menüs erlaubt)
- Wunsch-Voting im Web: auf /roadmap Wünsche einreichen (postet wie
  /wunsch in den Voting-Kanal) und upvoten (wish_votes-Tabelle,
  ein Vote pro Member, unabhängig von Discord-👍)
- Member-Gate: Login nur für Mitglieder der konfigurierten Guild;
  Abgewiesene sehen einen Banner mit Discord-Invite-Link (neue
  Settings member_gate_enabled + discord_invite_url im System-Tab)
- Membership-Check mit 5-Minuten-Cache, Owner immer erlaubt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:52:59 +02:00

1521 lines
81 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';
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' },
];
const TABS = [
{ id: 'status', icon: '📊', label: 'Status' },
{ id: 'brand', icon: '🎨', label: 'Brand' },
{ id: 'feeds', icon: '📔', label: 'Feeds' },
{ id: 'community', icon: '🙌', label: 'Community' },
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
{ id: 'support', icon: '📬', label: 'Support' },
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
{ id: 'composer', icon: '📝', label: 'Composer' },
{ id: 'server', icon: '🎮', label: 'Server' },
{ id: 'system', icon: '⚙️', label: 'System' },
{ id: 'api', icon: '🔑', label: 'API' },
{ id: 'team', icon: '👥', label: 'Team' },
];
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
const TAB_SCOPES = {
status: 'any',
brand: null,
feeds: 'settings',
community: 'community',
rollen: 'rollen',
support: 'settings',
bewerbungen: 'bewerbungen',
composer: '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 }) {
const [tab, setTab] = useState('status');
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: '' };
const [servers, setServers] = useState([]);
const [serverDraft, setServerDraft] = useState(emptyServer);
// Bot-Beschreibung + Team
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
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));
// 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(() => {});
}, [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);
}
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">SETUP</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Admin</div>
<h1 className="page-title">Setup</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>🔒 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 ───────────────────────────────── */
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>
<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>
);
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">🤖</div>}
<span className="bot-dot" style={{ background: statusDot }} />
</div>
<label className="btn btn-upload">
Bild ändern
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
</label>
<label className="btn btn-upload btn-ghost">
🖼 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">// 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}
>
🎟️ 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 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">// 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>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>
);
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 ?? '',
})}>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>
<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 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>
</div>
);
const content = {
status: tabStatus,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
rollen: tabRollen,
support: tabSupport,
bewerbungen: tabBewerbungen,
composer: tabComposer,
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">
{visibleTabs.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>
))}
</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>
</>
);
}