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 (
setOpen(true)}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }}
/>
{ e.preventDefault(); setOpen((o) => !o); }}>▾
{open && filtered.length > 0 && (
{filtered.map(([id, label]) => (
{ e.preventDefault(); onChange(id); setOpen(false); }}
>
{label}
{id}
))}
)}
);
}
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', '🔧 Garry’s Mod'], ['satisfactory', '🏭 Satisfactory'],
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
['unturned', '🏝️ Unturned'],
];
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '' };
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 = (
SETUP
// Admin
Setup
Alles an einem Ort — Änderungen greifen sofort.
);
if (me.loading) return <>{header}>;
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
return (
<>
{header}
>
);
}
if (error) return <>{header}Fehler beim Laden ({error}).
>;
if (!data || !form) return <>{header}>;
const channelOptions = data.channels.map((c) => (
#{c.name} — {c.guild}
));
const roleOptions = (data.roles ?? []).map((r) => (
@{r.name} — {r.guild}
));
const channelName = (id) => {
const c = data.channels.find((x) => x.id === id);
return c ? `#${c.name}` : id;
};
/* ── Tab-Inhalte ───────────────────────────────── */
const tabStatus = (
// Status
{data.status.botTag ?? '—'} Bot
{fmtUptime(data.status.uptimeSeconds)} Uptime
{data.status.devlogs} Devlogs
{data.status.commits} Commits
{fmtBytes(data.status.dbSizeBytes)} Datenbank
{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}
Letztes Backup
);
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
const tabBrand = (
<>
Bot-Name Discord erlaubt ~2 Umbenennungen pro Stunde
setBotName(e.target.value)} />
Beschreibung „Über mich" im Bot-Profil (max 400 Zeichen)
Bot-Status
setForm({ ...form, bot_presence_status: e.target.value })}>
🟢 Online
🌙 Abwesend
⛔ Bitte nicht stören
Speichern & anwenden
>
);
const tabFeeds = (
<>
// Kanäle
Devlog-Kanal öffentlich — hier postet der Bot die Devlogs
setForm({ ...form, devlog_channel_id: e.target.value })}>
— Kanal wählen —
{channelOptions}
sendTest('devlog')}>Test
Commit-Kanal privat — hier landen die Push-Embeds
setForm({ ...form, commit_channel_id: e.target.value })}>
— Kanal wählen —
{channelOptions}
sendTest('commit')}>Test
Release-Kanal Ankündigungen bei neuen Gitea-Releases — leer = aus
setForm({ ...form, release_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
sendTest('release')}>Test
>
);
const tabCommunity = (
<>
// Playtester
Playtester-Rolle Bewerbungs-Post via /playtester-setup; leer = aus
setForm({ ...form, playtester_role_id: e.target.value })}>
— deaktiviert —
{roleOptions}
{playtesters.length > 0 && (
{playtesters.map((p) => (
{p.username}
seit {p.applied_at.slice(0, 10)}
))}
)}
// Alpha-Keys
{alphaKeys?.free ?? '—'} Frei
{alphaKeys?.assigned?.length ?? '—'} Vergeben
{alphaKeys?.playtestersWithout?.length ?? '—'} Warten
Keys hinzufügen einer pro Zeile
Hinzufügen
🎟️ An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
{alphaKeys?.assigned?.length > 0 && (
{alphaKeys.assigned.map((k) => (
{k.key}
→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}
))}
)}
// Starboard & Galerie
Screenshot-Kanal Bilder → öffentliche Galerie; Historie: /galerie-backfill
setForm({ ...form, screenshot_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Voting-Kanal /wunsch postet hier; Rangliste auf /roadmap
setForm({ ...form, voting_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
>
);
const tabRollen = (
<>
// Rollen bei Join
Auto-Rolle bekommt jeder neue Member automatisch — leer = aus
setForm({ ...form, autorole_id: e.target.value })}>
— deaktiviert —
{roleOptions}
setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
Sticky-Roles
wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder
Speichern
// Rollen-Menüs
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.
{menus.length > 0 && (
{menus.map((m) => (
{m.title}
{m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
{m.exclusive ? ' · exklusiv' : ''}
{m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
editMenu(m)}>Bearbeiten
removeMenu(m.id)}>✕
))}
)}
Beschreibung optional, steht im Embed über den Buttons
setMenuDraft({ ...menuDraft, description: e.target.value })} />
setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
Exklusiv
max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)
Rollen Emoji optional (auch Custom als <:name:id>), Label = Button-Text
{menuDraft.entries.map((entry, i) => (
setMenuEntry(i, { emoji: e.target.value })} />
setMenuEntry(i, { label: e.target.value })} />
setMenuEntry(i, { role_id: e.target.value })}>
— Rolle —
{roleOptions}
setMenuEntry(i, { style: e.target.value })}
>
⬜ Grau
🟦 Blau
🟩 Grün
🟥 Rot
setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
disabled={menuDraft.entries.length === 1}
>✕
))}
setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
+ Rolle
saveMenu(false)}>Speichern
saveMenu(true)}>Speichern + posten
{menuDraft.id && (
setMenuDraft(emptyMenu)}>Abbrechen
)}
>
);
const tabSupport = (
// Moderation & Kontakt
Modmail-Kanal PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads
setForm({ ...form, modmail_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Ticket-Kanal private Ticket-Threads; Post via /ticket-setup
setForm({ ...form, ticket_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Willkommens-Kanal Begrüßungs-Embed für neue Member
setForm({ ...form, welcome_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Mod-Log-Kanal PRIVAT — gelöschte/bearbeitete Nachrichten
setForm({ ...form, modlog_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
);
const tabBewerbungen = (
// Bewerbungs-Formulare
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.
{appForms.length > 0 && (
{appForms.map((f) => (
{f.title}
{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}
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
removeForm(f.id)}>✕
))}
)}
setFormDraft({ ...formDraft, description: e.target.value })} />
setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
— Post-Kanal (öffentlich) —
{channelOptions}
setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
— Review-Kanal (Staff) —
{channelOptions}
setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
— Rolle bei Annahme —
{roleOptions}
Fragen max 5 (Discord-Modal-Limit)
{formDraft.questions.map((q, i) => (
setFormDraft({
...formDraft,
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
})} />
setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}>✕
))}
= 5}
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage
saveForm(false)}>Speichern
saveForm(true)}>Speichern + posten
{formDraft.id && setFormDraft(emptyForm)}>Abbrechen }
);
const tabComposer = (
<>
// Geplante Posts
{scheduled.length > 0 && (
{scheduled.map((p) => (
{channelName(p.channel_id)}
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
{p.enabled ? '' : ' · erledigt'}
{p.content.slice(0, 40)}…
removeSchedule(p.id)}>✕
))}
)}
Neuer geplanter Post
setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
— Kanal wählen —
{channelOptions}
setSchedDraft({ ...schedDraft, type: e.target.value })}>
einmalig
täglich
wöchentlich
{schedDraft.type === 'once' && (
setSchedDraft({ ...schedDraft, date: e.target.value })} />
)}
{schedDraft.type === 'weekly' && (
setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
{d}
))}
)}
setSchedDraft({ ...schedDraft, time: e.target.value })} />
setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
Planen
// Triggers
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
{triggers.length > 0 && (
{triggers.map((t) => (
{t.keyword}
{t.reply.slice(0, 50)}
setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten
removeTrigger(t.keyword)}>✕
))}
)}
// Tags (/tag)
Text-Bausteine für wiederkehrende Antworten — jeder Member kann sie mit /tag name abrufen.
{tags.length > 0 && (
{tags.map((t) => (
{t.name}
{t.content.slice(0, 50)}
setTagDraft({ name: t.name, content: t.content })}>Bearbeiten
removeTag(t.name)}>✕
))}
)}
>
);
const tabSystem = (
<>
// Events, Social & Voice
Event-Ankündigungs-Kanal neue Discord-Events werden hier angekündigt + Events-Seite
setForm({ ...form, events_announce_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Social-Kanal 🔴 Twitch-Live + ▶️ neue YouTube-Videos
setForm({ ...form, social_announce_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Temp-Voice-Hub „Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)
setForm({ ...form, tempvoice_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
>
);
const tabApi = (
// API-Keys (/api/v1)
{keys.length > 0 && (
{keys.map((k) => (
{k.name}
{k.scopes}
{k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
revokeKey(k.id)}>✕
))}
)}
{createdKey && (
Key {createdKey.name} erstellt — jetzt kopieren , er wird nie wieder angezeigt:
{createdKey.key}
)}
);
const tabServer = (
<>
// Game-Server
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
Spieler-Balken, Map, Adresse) — aktualisiert alle 2 Minuten, DiscordGSM-Style.
Status-Kanal
setForm({ ...form, status_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Alert-Kanal 🚨 down / ✅ online
setForm({ ...form, server_alert_channel_id: e.target.value })}>
— keine Alerts —
{channelOptions}
Kanäle speichern
{servers.length > 0 && (
{servers.map((s) => (
{s.name}
{s.type}
{s.query_url}
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
removeServer(s.id)}>✕
))}
)}
>
);
const tabTeam = (
// Team
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.
{team.length > 0 && (
{team.map((t) => (
{t.username ?? t.user_id}
{t.scopes}
setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten
removeTeam(t.user_id)}>✕
))}
)}
);
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}
{visibleTabs.map((t) => (
setTab(t.id)}
>
{t.icon}
{t.label}
))}
{content}
{showSave && (
Speichern
)}
{feedback && {feedback}
}
>
);
}