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 (
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 }) {
// 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', '🔧 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: '', 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 = (
CONFIG
// Admin
Config
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 ───────────────────────────────── */
// 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 = (
<>
// Status
{data.status.botTag ?? '—'} Bot
{fmtUptime(data.status.uptimeSeconds)} Uptime
{modules.length > 0 && (
{activeModules.length}/{modules.length}
Module an
)}
{data.status.devlogs} Devlogs
{data.status.commits} Commits
{fmtBytes(data.status.dbSizeBytes)} Datenbank
{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}
Letztes Backup
{modules.length > 0 && (
// Noch einzurichten
{unfinished.length === 0 ? (
Alles eingerichtet — jedes eingeschaltete Modul hat, was es braucht.
) : (
<>
Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas —
bis dahin tun sie nichts.
{unfinished.map((m) => (
{m.name}
{m.missing.join(', ')}
{m.tab && (
setTab(m.tab)}>
Einrichten
)}
))}
>
)}
)}
>
);
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 = (
<>
// Geburtstage
Geburtstags-Kanal Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus
setForm({ ...form, birthday_channel_id: e.target.value })}>
— deaktiviert —
{channelOptions}
Geburtstags-Rolle gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine
setForm({ ...form, birthday_role_id: e.target.value })}>
— keine Rolle —
{roleOptions}
// 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 = (
<>
// Zwei Seiten
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.
Community-Hub Devlogs, Roadmap, Server, Profile — Ziel für Devlog-Buttons, RSS und Link-Vorschauen
setForm({ ...form, hub_url: e.target.value })} />
Bot-Produktseite Funktionen, Befehle und dieses Dashboard
setForm({ ...form, bot_url: e.target.value })} />
Login-Domain z. B. .d4rkst3r.de — dann gilt die Anmeldung auf beiden Seiten; leer = getrennte Logins
setForm({ ...form, cookie_domain: e.target.value })} />
Bug-Repo Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}
setForm({ ...form, bug_report_repo: e.target.value })} />
Roadmap-Repo Milestones → öffentliche /roadmap
setForm({ ...form, roadmap_repo: e.target.value })} />
// Dienste (Portal)
Erscheinen als Kacheln auf der Startseite — z. B. Gitea, Kanban oder die Cloud.
{services.length > 0 && (
{services.map((s) => (
{s.icon} {s.name}
{s.url}
setServiceDraft({
id: s.id, name: s.name, url: s.url, icon: s.icon,
description: s.description ?? '', sort: s.sort ?? 0,
})}>Bearbeiten
removeService(s.id)}>✕
))}
)}
// 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}
)}
// Single Sign-On
Andere Dienste (Kanban, Platform …) können den Discord-Login mitnutzen: Sie
leiten auf /sso/authorize weiter und tauschen den Token per
POST /sso/verify gegen Nutzerdaten samt Discord-Rollen ein.
{ssoApps.length > 0 && (
{ssoApps.map((a) => (
{a.name}
{a.slug}
{a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'}
removeSsoApp(a.slug)}>✕
))}
)}
{createdSso && (
App {createdSso.name} registriert — Secret jetzt kopieren ,
es wird nie wieder angezeigt:
{createdSso.secret}
)}
>
);
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 ?? '',
image_url: s.image_url ?? '',
})}>Bearbeiten
removeServer(s.id)}>✕
))}
)}
>
);
const tabModule = (
// Module
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.
{moduleGroups.map((group) => {
const mods = modules.filter((m) => m.group === group.id);
if (mods.length === 0) return null;
return (
{group.label}
{mods.map((m) => (
{m.name}
toggleModule(m.id, !m.enabled)}
>
{m.desc}
{m.enabled && m.missing.length > 0 && (
Fehlt noch: {m.missing.join(', ')}
)}
{m.enabled && m.missing.length === 0 && (
Einsatzbereit
)}
{m.tab && (
setTab(m.tab)}>
Einstellungen →
)}
))}
);
})}
);
const tabTexte = (
// Texte
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.
{templateGroups.map((group) => {
const items = templates.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
{group.label}
{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 (
{t.name}
{t.customized && angepasst }
{off && Modul aus }
);
})}
);
})}
);
const tabSeiten = (
// Eigene Seiten
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.
{pages.length > 0 && (
{pages.map((p) => (
{p.title}
/{p.slug}
{p.published ? (p.in_menu ? 'sichtbar · im Menü' : 'sichtbar · nur per Link') : 'Entwurf'}
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
removePage(p.slug)}>
))}
)}
Inhalt Markdown: **fett**, *kursiv*, ## Überschrift, - Liste, [Link](url)
Vorschau
{pageDraft.content.trim()
?
:
Noch kein Inhalt.
}
setPageDraft({ ...pageDraft, published: e.target.checked })}
/>
Veröffentlicht
aus = Entwurf, nur für dich sichtbar
setPageDraft({ ...pageDraft, in_menu: e.target.checked })}
/>
Im Community-Menü zeigen
aus = nur über den Direktlink erreichbar
setPageDraft({ ...pageDraft, sort: e.target.value })}
/>
{pageDraft.isNew ? 'Anlegen' : 'Speichern'}
{!pageDraft.isNew && (
setPageDraft(emptyPage)}>Neue Seite
)}
);
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)}>✕
))}
)}
{audit.length > 0 && (
<>
// Audit-Log
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
{audit.map((a) => (
{a.ts.slice(0, 16).replace('T', ' ')}
{a.username}
{a.action}
{a.detail}
))}
>
)}
);
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}
setTabSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && foundTabs[0]) {
setTab(foundTabs[0].id);
setTabSearch('');
}
if (e.key === 'Escape') setTabSearch('');
}}
/>
{tabSearch && (
setTabSearch('')}>
)}
{foundTabs.length === 0 && (
Nichts gefunden.
)}
{TAB_SECTIONS.map((section) => {
const items = foundTabs.filter((t) => t.section === section.id);
if (items.length === 0) return null;
return (
{section.label}
{items.map((t) => (
setTab(t.id)}
>
{t.label}
))}
);
})}
{content}
{showSave && (
Speichern
)}
{feedback && {feedback}
}
>
);
}