Discords eigener Filter arbeitet serverseitig und ohne Latenz — daran ist nichts zu verbessern. Was fehlte, war die Sicht darauf: Treffer landeten nur in Discords eigenem Kanal, und die Regeln pflegte man in einer ganz anderen Oberflaeche als alles andere. Jetzt spiegelt der Bot jeden Treffer als Embed ins Mod-Log — mit Nutzer, Regel, Ausloeser, Massnahme, getroffenem Wort und gekuerztem Wortlaut, ohne Erwaehnungen, die den Kanal anpingen. Im Panel steht unter Support jede Regel mit Ausloeser, Massnahmen und der Zahl ausgenommener Rollen und Kanaele — letzteres oft der Grund, warum eine Regel scheinbar nicht greift. Schalten geht dort direkt. Das Bearbeiten der Wortlisten bleibt bewusst in Discord; dort ist es gut geloest, und ein zweiter Ort dafuer waere nur eine weitere Stelle, an der etwas auseinanderlaeuft. Dazu noetig: der Gateway-Intent AutoModerationExecution. Nicht privilegiert, aber Discord schickt die Ereignisse nur an Bots mit "Server verwalten" — fehlt das Recht, sagt das Panel es statt still leer zu bleiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3404 lines
180 KiB
React
3404 lines
180 KiB
React
import { useEffect, useRef, useState } from 'react';
|
||
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
|
||
import EmbedComposer from '../components/EmbedComposer.jsx';
|
||
import {
|
||
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
|
||
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
|
||
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
|
||
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
|
||
IconSearch, IconSystem as IconTune, IconStar,
|
||
} from '../icons.jsx';
|
||
import { moduleIcon } from '../module-icons.jsx';
|
||
import ModulePage from '../components/ModulePage.jsx';
|
||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||
import { findSettings, SETTING_INDEX } from '../setting-index.js';
|
||
import { Markdown } from '../markdown.jsx';
|
||
|
||
const API_SCOPES = [
|
||
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
||
{ id: 'dm', label: 'dm', hint: 'Direktnachrichten senden' },
|
||
{ id: 'roles', label: 'roles', hint: 'Rollen geben/nehmen' },
|
||
{ id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' },
|
||
];
|
||
|
||
// Bereiche der Seitenleiste — bei 15 Tabs findet man ohne Gruppierung nichts mehr.
|
||
const TAB_SECTIONS = [
|
||
{ id: 'ueberblick', label: 'Überblick' },
|
||
{ id: 'auftritt', label: 'Auftritt' },
|
||
{ id: 'inhalte', label: 'Inhalte' },
|
||
{ id: 'community', label: 'Community' },
|
||
{ id: 'technik', label: 'Technik' },
|
||
{ id: 'zugang', label: 'Zugang' },
|
||
];
|
||
|
||
// `find` sind zusätzliche Suchbegriffe: was jemand tippt, wenn er den Tab-Namen
|
||
// nicht kennt („geburtstag" → Community, „willkommen" → Support).
|
||
const TABS = [
|
||
{ id: 'status', Icon: IconStatus, label: 'Status', section: 'ueberblick',
|
||
find: 'uptime laufzeit datenbank backup version zahlen übersicht' },
|
||
{ id: 'module', Icon: IconLayers, label: 'Module', section: 'ueberblick',
|
||
find: 'funktionen an aus schalter aktivieren deaktivieren' },
|
||
{ id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick',
|
||
find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' },
|
||
{ id: 'werte', Icon: IconTune, label: 'Werte', section: 'ueberblick',
|
||
find: 'xp cooldown wartezeit intervall prüfung timeout obergrenze zahlen uhrzeit' },
|
||
|
||
{ id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt',
|
||
find: 'avatar banner farbe logo name footer aussehen embed' },
|
||
{ id: 'seiten', Icon: IconPages, label: 'Seiten', section: 'auftritt',
|
||
find: 'regeln über uns faq markdown menü impressum' },
|
||
|
||
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds', section: 'inhalte',
|
||
find: 'devlog commit release changelog rückblick threads ping' },
|
||
{ id: 'composer', Icon: IconComposer, label: 'Composer', section: 'inhalte',
|
||
find: 'nachricht senden embed bauen tags trigger geplant zeitplan' },
|
||
|
||
{ id: 'community', Icon: IconCommunity, label: 'Community', section: 'community',
|
||
find: 'level xp starboard galerie geburtstag playtester alpha keys voting wünsche autorolle' },
|
||
{ id: 'rollen', Icon: IconRoles, label: 'Rollen', section: 'community',
|
||
find: 'rollen menü selbstbedienung buttons reaction roles' },
|
||
{ id: 'support', Icon: IconSupport, label: 'Support', section: 'community',
|
||
find: 'ticket modmail willkommen protokoll modlog moderation warnung' },
|
||
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen', section: 'community',
|
||
find: 'formular fragen bewerbung prüfen annehmen ablehnen' },
|
||
|
||
{ id: 'server', Icon: IconServer, label: 'Server', section: 'technik',
|
||
find: 'game server monitor fivem minecraft status spieler gamedig' },
|
||
{ id: 'system', Icon: IconSystem, label: 'System', section: 'technik',
|
||
find: 'backup gitea watchdog twitch youtube events sprachkanäle impressum datenschutz' },
|
||
|
||
{ id: 'api', Icon: IconApi, label: 'API', section: 'zugang',
|
||
find: 'schlüssel key token zugriff sso webhook' },
|
||
{ id: 'team', Icon: IconTeam, label: 'Team', section: 'zugang',
|
||
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
|
||
];
|
||
|
||
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
|
||
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
||
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
|
||
|
||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||
const TAB_SCOPES = {
|
||
status: 'any',
|
||
module: 'settings',
|
||
texte: 'settings',
|
||
werte: 'settings',
|
||
brand: null,
|
||
feeds: 'settings',
|
||
community: 'community',
|
||
rollen: 'rollen',
|
||
support: 'settings',
|
||
bewerbungen: 'bewerbungen',
|
||
composer: 'content',
|
||
seiten: 'content',
|
||
server: 'server',
|
||
system: null,
|
||
api: null,
|
||
team: null,
|
||
};
|
||
|
||
/** „vor 3 Minuten" statt rohem Zeitstempel */
|
||
/**
|
||
* Zeitstempel aus der Datenbank lesen — es kommen zwei Formate an:
|
||
*
|
||
* "2026-07-30 12:34:56" SQLite datetime('now'), UTC ohne Kennzeichnung
|
||
* "2026-07-30T12:34:56.789Z" toISOString(), Zone schon dabei
|
||
*
|
||
* Beim ersten muss das Z ergänzt werden, sonst rechnet der Browser die Zeit
|
||
* als Ortszeit. Beim zweiten darf es das gerade nicht — "…ZZ" ergibt ein
|
||
* ungültiges Datum und in der Anzeige stand dann NaN.
|
||
*/
|
||
function alsDatum(wert) {
|
||
if (!wert) return null;
|
||
const s = String(wert).trim();
|
||
const hatZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(s);
|
||
const d = new Date(hatZone ? s : `${s.replace(' ', 'T')}Z`);
|
||
return Number.isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
function vorWieLange(wert) {
|
||
const d = alsDatum(wert);
|
||
if (!d) return '—';
|
||
const min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||
if (min < 1) return 'gerade eben';
|
||
if (min < 60) return `vor ${min} Min`;
|
||
const std = Math.floor(min / 60);
|
||
if (std < 24) return `vor ${std} Std`;
|
||
const tage = Math.floor(std / 24);
|
||
return tage === 1 ? 'gestern' : `vor ${tage} Tagen`;
|
||
}
|
||
|
||
/** Avatar mit Anfangsbuchstaben als Rückfallebene.
|
||
* Bewusst außerhalb der Komponente: als Inline-Komponente wäre der Typ bei
|
||
* jedem Rendern neu und React würde die Bilder jedes Mal neu einhängen. */
|
||
const Avatar = ({ src, name, klein }) => (src
|
||
? <img className={`tm-avatar${klein ? ' klein' : ''}`} src={src} alt="" loading="lazy" />
|
||
: <span className={`tm-avatar${klein ? ' klein' : ''} ohne`}>{(name ?? '?').slice(0, 1).toUpperCase()}</span>);
|
||
|
||
const TEAM_SCOPE_LABELS = {
|
||
content: 'Composer, Tags, Triggers & geplante Posts',
|
||
community: 'Playtester & Alpha-Keys',
|
||
rollen: 'Rollen-Menüs',
|
||
bewerbungen: 'Bewerbungen',
|
||
server: 'Game-Server',
|
||
settings: 'Kanäle & Feed-Einstellungen',
|
||
devlogs: 'Devlogs löschen',
|
||
};
|
||
|
||
/** Sekunden → "2d 4h 13m" */
|
||
/** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon.
|
||
* Filtert die Presets beim Tippen, freie gamedig-IDs bleiben erlaubt. */
|
||
function GameSelect({ presets, value, onChange }) {
|
||
const [open, setOpen] = useState(false);
|
||
const wrapRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
function onDocClick(e) {
|
||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||
}
|
||
document.addEventListener('mousedown', onDocClick);
|
||
return () => document.removeEventListener('mousedown', onDocClick);
|
||
}, []);
|
||
|
||
const q = value.trim().toLowerCase();
|
||
const filtered = presets.filter(([id, label]) =>
|
||
!q || id.includes(q) || label.toLowerCase().includes(q)
|
||
);
|
||
|
||
return (
|
||
<div className="gamesel" ref={wrapRef} style={{ flex: 'none', width: '13rem' }}>
|
||
<input
|
||
type="text" placeholder="Spiel (gamedig-ID)"
|
||
title="Vorschläge oder beliebige gamedig-Spiel-ID"
|
||
value={value}
|
||
onFocus={() => setOpen(true)}
|
||
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
|
||
onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }}
|
||
/>
|
||
<span className="gamesel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>▾</span>
|
||
{open && filtered.length > 0 && (
|
||
<div className="gamesel-list">
|
||
{filtered.map(([id, label]) => (
|
||
<button
|
||
type="button" key={id}
|
||
className={`gamesel-item${id === value ? ' active' : ''}`}
|
||
onMouseDown={(e) => { e.preventDefault(); onChange(id); setOpen(false); }}
|
||
>
|
||
<span className="gamesel-label">{label}</span>
|
||
<span className="gamesel-id">{id}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Durchsuchbare Auswahl für Kanäle und Rollen — dieselbe Bedienung wie bei den
|
||
* gamedig-IDs. Ein natives <select> lässt sich nicht durchsuchen; bei vielen
|
||
* Kanälen scrollt man sonst durch eine lange Liste.
|
||
*
|
||
* @param {{
|
||
* items: Array<{ id: string, label: string, hint?: string }>,
|
||
* value: string, onChange: (id: string) => void, empty: string,
|
||
* }} props — `empty` ist die Beschriftung für „nichts gewählt"
|
||
*/
|
||
function PickSelect({ items, value, onChange, empty }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState('');
|
||
const wrapRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
function onDocClick(e) {
|
||
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
||
setOpen(false);
|
||
setQuery('');
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', onDocClick);
|
||
return () => document.removeEventListener('mousedown', onDocClick);
|
||
}, []);
|
||
|
||
const current = items.find((i) => i.id === value);
|
||
const q = query.trim().toLowerCase();
|
||
const filtered = q
|
||
? items.filter((i) => `${i.label} ${i.hint ?? ''}`.toLowerCase().includes(q))
|
||
: items;
|
||
|
||
return (
|
||
<div className="picksel" ref={wrapRef}>
|
||
{/* Zugeklappt steht die Auswahl im Feld, aufgeklappt tippt man die Suche */}
|
||
<input
|
||
type="text"
|
||
value={open ? query : (current?.label ?? '')}
|
||
placeholder={current ? current.label : empty}
|
||
onFocus={() => { setOpen(true); setQuery(''); }}
|
||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') { setOpen(false); setQuery(''); e.target.blur(); }
|
||
if (e.key === 'Enter' && filtered[0]) {
|
||
onChange(filtered[0].id); setOpen(false); setQuery(''); e.target.blur();
|
||
}
|
||
}}
|
||
/>
|
||
<span className="picksel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>
|
||
<IconChevronDown size={14} />
|
||
</span>
|
||
{open && (
|
||
<div className="picksel-list">
|
||
<button
|
||
type="button" className={`picksel-item${value === '' ? ' active' : ''}`}
|
||
onMouseDown={(e) => { e.preventDefault(); onChange(''); setOpen(false); setQuery(''); }}
|
||
>
|
||
<span className="picksel-label muted">{empty}</span>
|
||
</button>
|
||
{filtered.map((i) => (
|
||
<button
|
||
type="button" key={i.id}
|
||
className={`picksel-item${i.id === value ? ' active' : ''}`}
|
||
onMouseDown={(e) => { e.preventDefault(); onChange(i.id); setOpen(false); setQuery(''); }}
|
||
>
|
||
<span className="picksel-label">{i.label}</span>
|
||
{i.hint && <span className="picksel-hint">{i.hint}</span>}
|
||
</button>
|
||
))}
|
||
{filtered.length === 0 && <span className="picksel-none">Nichts gefunden</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fmtUptime(s) {
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
|
||
}
|
||
|
||
function fmtBytes(b) {
|
||
if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||
return `${(b / 1024).toFixed(0)} KB`;
|
||
}
|
||
|
||
export default function Settings({ me }) {
|
||
// Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten
|
||
const [tab, setTabState] = useState(
|
||
() => TABS.find((t) => t.id === window.location.hash.slice(1).split('/')[0])?.id ?? 'status'
|
||
);
|
||
const setTab = (id) => {
|
||
setTabState(id);
|
||
setOpenModuleState(null);
|
||
window.history.replaceState(null, '', `#${id}`);
|
||
};
|
||
const [tabSearch, setTabSearch] = useState('');
|
||
const [data, setData] = useState(null);
|
||
const [form, setForm] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [feedback, setFeedback] = useState(null);
|
||
// Community
|
||
const [playtesters, setPlaytesters] = useState([]);
|
||
// Rollen-Menüs
|
||
const emptyMenu = {
|
||
id: null, title: '', description: '', channel_id: '', exclusive: false,
|
||
entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
|
||
};
|
||
const [menus, setMenus] = useState([]);
|
||
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||
// Branding
|
||
const [giteaToken, setGiteaToken] = useState('');
|
||
const [botName, setBotName] = useState('');
|
||
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
|
||
// Alpha-Keys
|
||
const [alphaKeys, setAlphaKeys] = useState(null);
|
||
const [keyUpload, setKeyUpload] = useState('');
|
||
// Bewerbungs-Formulare
|
||
const emptyForm = {
|
||
id: null, title: '', description: '', review_channel_id: '',
|
||
approve_role_id: '', post_channel_id: '', questions: [''],
|
||
};
|
||
const [appForms, setAppForms] = useState([]);
|
||
const [formDraft, setFormDraft] = useState(emptyForm);
|
||
// Triggers
|
||
const [triggers, setTriggers] = useState([]);
|
||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||
// Game-Server
|
||
const GAME_PRESETS = [
|
||
['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'],
|
||
['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'],
|
||
['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'],
|
||
['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'],
|
||
['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'],
|
||
['garrysmod', '🔧 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([]);
|
||
const [serverStats, setServerStats] = useState(null);
|
||
const [watchdog, setWatchdog] = useState(null);
|
||
const [automod, setAutomod] = useState(null);
|
||
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||
const [owner, setOwner] = useState(null);
|
||
const [auditAlle, setAuditAlle] = useState(false);
|
||
// Module (An/Aus für alle Funktionen)
|
||
const [modules, setModules] = useState([]);
|
||
const [moduleGroups, setModuleGroups] = useState([]);
|
||
const [modFilter, setModFilter] = useState('alle');
|
||
// Offene Modul-Seite steht mit in der Adresse: #module/welcome
|
||
const [openModule, setOpenModuleState] = useState(
|
||
() => window.location.hash.split('/')[1] ?? null
|
||
);
|
||
const setOpenModule = (id) => {
|
||
setOpenModuleState(id);
|
||
window.history.replaceState(null, '', id ? `#module/${id}` : '#module');
|
||
};
|
||
const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird
|
||
// Funktionen, die in der Seitenleiste stehen
|
||
const [favorites, setFavorites] = useState([]);
|
||
const [maxFavorites, setMaxFavorites] = useState(8);
|
||
// Grundgerüst-Assistent
|
||
const [plan, setPlan] = useState(null); // Vorschau, null = zu
|
||
const [setupRunning, setSetupRunning] = useState(false);
|
||
const [setupResult, setSetupResult] = useState(null);
|
||
// Text-Vorlagen (was der Bot nach außen schreibt)
|
||
const [templates, setTemplates] = useState([]);
|
||
const [templateGroups, setTemplateGroups] = useState([]);
|
||
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
|
||
const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage
|
||
// Stellwerte (XP, Wartezeiten, Intervalle, Obergrenzen)
|
||
const [tuningValues, setTuningValues] = useState([]);
|
||
const [tuningGroups, setTuningGroups] = useState([]);
|
||
const [tuneDraft, setTuneDraft] = useState({}); // id → eingetippter Wert
|
||
// Eigene Seiten (Regeln, Über uns, …)
|
||
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
|
||
const [pages, setPages] = useState([]);
|
||
const [pageDraft, setPageDraft] = useState(emptyPage);
|
||
// Portal-Dienste (Kacheln auf der Startseite)
|
||
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
||
const [services, setServices] = useState([]);
|
||
const [serviceDraft, setServiceDraft] = useState(emptyService);
|
||
// SSO-Apps (Fremd-Dienste mit Discord-Login)
|
||
const [ssoApps, setSsoApps] = useState([]);
|
||
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
|
||
const [createdSso, setCreatedSso] = useState(null);
|
||
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||
|
||
const canTab = (id) => {
|
||
if (me.admin) return true;
|
||
const need = TAB_SCOPES[id];
|
||
if (need === 'any') return (me.scopes?.length ?? 0) > 0;
|
||
return need ? me.scopes?.includes(need) : false;
|
||
};
|
||
const visibleTabs = TABS.filter((t) => canTab(t.id));
|
||
// Suche greift auf Beschriftung und Stichwörter — wer „geburtstag" tippt,
|
||
// landet bei Community, ohne den Tab-Namen zu kennen.
|
||
const query = tabSearch.trim().toLowerCase();
|
||
const foundTabs = query
|
||
? visibleTabs.filter((t) => `${t.label} ${t.find}`.toLowerCase().includes(query))
|
||
: visibleTabs;
|
||
// … und zusätzlich auf einzelne Einstellungen, mit Sprung aufs Feld
|
||
const foundSettings = query ? findSettings(query, canTab) : [];
|
||
|
||
/**
|
||
* Zum Feld einer Einstellung springen — der Bereich kommt aus dem Register,
|
||
* damit „Einrichten" nicht im falschen Tab landet (das Gitea-Token steht
|
||
* z. B. bei Brand, nicht bei System).
|
||
*/
|
||
function jumpToMissing(fehlt, fallbackTab) {
|
||
const bekannt = SETTING_INDEX.find((s) => s.id === fehlt.setting);
|
||
jumpToSetting(bekannt ?? { id: fehlt.setting, tab: fallbackTab });
|
||
}
|
||
|
||
/** Bereich wechseln, zum Feld scrollen und es kurz hervorheben */
|
||
function jumpToSetting(entry) {
|
||
setTab(entry.tab);
|
||
setTabSearch('');
|
||
const key = entry.target ?? entry.id;
|
||
// Zwei Bilder warten, bis der neue Bereich wirklich im Dokument steht
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
const el = document.querySelector(`[data-setting="${key}"]`);
|
||
if (!el) return;
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
el.classList.add('field-found');
|
||
setTimeout(() => el.classList.remove('field-found'), 2200);
|
||
// Nur Textfelder fokussieren: bei der Kanal-Auswahl würde der Fokus
|
||
// die Liste aufklappen und alles darunter verdecken.
|
||
const feld = el.querySelector('input, textarea');
|
||
if (feld && !feld.closest('.picksel')) feld.focus({ preventScroll: true });
|
||
}));
|
||
}
|
||
// Tags + geplante Posts
|
||
const [tags, setTags] = useState([]);
|
||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||
const [scheduled, setScheduled] = useState([]);
|
||
const [schedDraft, setSchedDraft] = useState({
|
||
channel_id: '', embed_title: '', content: '', type: 'once',
|
||
date: '', time: '18:00', weekday: '5',
|
||
});
|
||
// API-Keys
|
||
const [keys, setKeys] = useState([]);
|
||
const [newKeyName, setNewKeyName] = useState('');
|
||
const [newKeyScopes, setNewKeyScopes] = useState(['message']);
|
||
const [createdKey, setCreatedKey] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!me.admin && !(me.scopes?.length > 0)) return;
|
||
apiGet('/api/settings')
|
||
.then((d) => {
|
||
setData(d);
|
||
setForm(d.settings);
|
||
})
|
||
.catch((e) => setError(e.status));
|
||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||
apiGet('/api/webadmins')
|
||
.then((d) => { setTeam(d.admins); setOwner(d.owner); })
|
||
.catch(() => {});
|
||
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
||
// Fuers Dashboard: Mitgliederzahl und Nachrichten pro Tag
|
||
apiGet('/api/serverstats').then(setServerStats).catch(() => {});
|
||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||
apiGet('/api/automod').then(setAutomod).catch(() => {});
|
||
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
|
||
apiGet('/api/modules').then((d) => {
|
||
setModules(d.modules);
|
||
setModuleGroups(d.groups);
|
||
setFavorites(d.favorites ?? []);
|
||
setMaxFavorites(d.maxFavorites ?? 8);
|
||
}).catch(() => {});
|
||
apiGet('/api/texts').then((d) => {
|
||
setTemplates(d.templates);
|
||
setTemplateGroups(d.groups);
|
||
}).catch(() => {});
|
||
apiGet('/api/tuning').then((d) => {
|
||
setTuningValues(d.values);
|
||
setTuningGroups(d.groups);
|
||
}).catch(() => {});
|
||
}, [me.admin, me.scopes?.length]);
|
||
|
||
useEffect(() => {
|
||
if (data?.status?.botName) setBotName(data.status.botName);
|
||
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
||
}, [data]);
|
||
|
||
// Vorschau der Willkommens-Karte. Verzögert, damit nicht jeder Tastendruck
|
||
// ein Rendering auslöst — die Karte wird serverseitig mit sharp gebaut.
|
||
const [welcomePreviewUrl, setWelcomePreviewUrl] = useState('/api/welcome-preview.png');
|
||
useEffect(() => {
|
||
if (!form) return undefined;
|
||
const timer = setTimeout(() => {
|
||
const q = new URLSearchParams({
|
||
background: form.welcome_card_bg ?? '',
|
||
accent: form.welcome_card_accent ?? '',
|
||
accent2: form.welcome_card_accent2 ?? '',
|
||
text: form.welcome_card_text ?? '',
|
||
kicker: form.welcome_card_kicker ?? '',
|
||
sub: form.welcome_card_sub ?? '',
|
||
image: form.welcome_card_image ?? '',
|
||
watermark: form.welcome_card_watermark ? '1' : '0',
|
||
});
|
||
setWelcomePreviewUrl(`/api/welcome-preview.png?${q}`);
|
||
}, 450);
|
||
return () => clearTimeout(timer);
|
||
}, [
|
||
form?.welcome_card_bg, form?.welcome_card_accent, form?.welcome_card_accent2,
|
||
form?.welcome_card_text, form?.welcome_card_kicker, form?.welcome_card_sub,
|
||
form?.welcome_card_image, form?.welcome_card_watermark,
|
||
]);
|
||
|
||
// Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln
|
||
useEffect(() => {
|
||
const onHash = () => {
|
||
const id = TABS.find((t) => t.id === window.location.hash.slice(1).split('/')[0])?.id;
|
||
if (id) setTabState(id);
|
||
};
|
||
window.addEventListener('hashchange', onHash);
|
||
return () => window.removeEventListener('hashchange', onHash);
|
||
}, []);
|
||
|
||
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
|
||
useEffect(() => {
|
||
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
|
||
}, [me.admin, me.scopes]);
|
||
|
||
async function saveTeam() {
|
||
if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) {
|
||
flash('✗ User-ID und mindestens ein Bereich nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPut('/api/webadmins', teamDraft);
|
||
setTeam(res.admins);
|
||
setTeamDraft({ user_id: '', scopes: [] });
|
||
flash('✓ Team-Mitglied gespeichert');
|
||
} catch {
|
||
flash('✗ Fehlgeschlagen — User-ID prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeTeam(userId) {
|
||
if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return;
|
||
const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null);
|
||
if (res) setTeam(res.admins);
|
||
}
|
||
|
||
async function toggleModule(id, enabled) {
|
||
// Sofort umschalten, damit sich der Schalter nicht träge anfühlt
|
||
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled } : m)));
|
||
try {
|
||
const res = await apiPut(`/api/modules/${id}`, { enabled });
|
||
setModules(res.modules);
|
||
} catch {
|
||
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled: !enabled } : m)));
|
||
flash('✗ Umschalten fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
/** Vorlage speichern. Leerer Text stellt den Standard wieder her. */
|
||
async function saveTemplate(id, text) {
|
||
try {
|
||
const res = await apiPut(`/api/texts/${encodeURIComponent(id)}`, { text });
|
||
setTemplates(res.templates);
|
||
setTplDraft(({ [id]: _drop, ...rest }) => rest);
|
||
flash(text.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
/** Funktion in der Seitenleiste an- oder abheften */
|
||
async function toggleFavorite(id) {
|
||
const dabei = favorites.includes(id);
|
||
const neu = dabei ? favorites.filter((f) => f !== id) : [...favorites, id];
|
||
const vorher = favorites;
|
||
setFavorites(neu); // sofort umschalten
|
||
try {
|
||
const res = await apiPut('/api/module-favorites', { ids: neu });
|
||
setFavorites(res.favorites);
|
||
} catch (error) {
|
||
setFavorites(vorher); // bei Ablehnung zurück
|
||
flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
/** Vorschau holen: was würde der Assistent anlegen? */
|
||
async function loadPlan() {
|
||
try {
|
||
const res = await apiGet('/api/setup/plan');
|
||
setPlan(res);
|
||
} catch (error) {
|
||
flash(`✗ ${error.body?.error ?? 'Vorschau fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
/** Grundgerüst anlegen — erst nach der Vorschau erreichbar */
|
||
async function runSetup() {
|
||
setSetupRunning(true);
|
||
try {
|
||
const res = await apiPost('/api/setup/run', { kategorie: plan.kategorie });
|
||
setModules(res.modules);
|
||
await reloadSettings();
|
||
setSetupResult(res.ergebnisse);
|
||
setPlan(null);
|
||
const neu = res.ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length;
|
||
flash(`✓ ${neu} neu angelegt`);
|
||
} catch (error) {
|
||
flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`);
|
||
} finally {
|
||
setSetupRunning(false);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kanäle, Rollen und Einstellungen neu holen. Nötig nach dem Anlegen: der
|
||
* frische Kanal steht sonst zwar in der Einstellung, fehlt aber in der
|
||
* Auswahlliste — das Feld sähe leer aus, obwohl es gesetzt ist.
|
||
*/
|
||
async function reloadSettings() {
|
||
const frisch = await apiGet('/api/settings').catch(() => null);
|
||
if (!frisch) return;
|
||
setData(frisch);
|
||
setForm(frisch.settings);
|
||
}
|
||
|
||
/** Fehlenden Kanal / fehlende Rolle vom Bot anlegen lassen */
|
||
async function createMissing(entry) {
|
||
setCreating(entry.setting);
|
||
try {
|
||
const res = await apiPost('/api/setup/create', { setting: entry.setting });
|
||
setModules(res.modules);
|
||
await reloadSettings();
|
||
flash(res.wiederverwendet
|
||
? `✓ Vorhandenen ${res.art === 'rolle' ? 'Rolle' : 'Kanal'} „${res.name}" verknüpft`
|
||
: `✓ ${res.art === 'rolle' ? 'Rolle' : 'Kanal'} „${res.name}" angelegt`);
|
||
} catch (error) {
|
||
flash(`✗ ${error.body?.error ?? 'Anlegen fehlgeschlagen'}`);
|
||
} finally {
|
||
setCreating(null);
|
||
}
|
||
}
|
||
|
||
/** Alle geänderten Stellwerte auf einmal speichern */
|
||
async function saveTuning() {
|
||
const changed = Object.fromEntries(
|
||
Object.entries(tuneDraft).filter(([id, v]) => {
|
||
const current = tuningValues.find((t) => t.id === id);
|
||
return current && String(v) !== String(current.value);
|
||
})
|
||
);
|
||
if (Object.keys(changed).length === 0) return;
|
||
try {
|
||
const res = await apiPut('/api/tuning', changed);
|
||
setTuningValues(res.values);
|
||
setTuneDraft({});
|
||
flash('✓ Werte gespeichert');
|
||
} catch (error) {
|
||
flash(`✗ ${error.body?.error ?? 'Speichern fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
/** Einen Wert auf den Standard zurücksetzen */
|
||
async function resetTuning(id) {
|
||
try {
|
||
const res = await apiPut('/api/tuning', { [id]: '' });
|
||
setTuningValues(res.values);
|
||
setTuneDraft(({ [id]: _drop, ...rest }) => rest);
|
||
flash('✓ Auf den Standard zurückgesetzt');
|
||
} catch {
|
||
flash('✗ Zurücksetzen fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
/** Platzhalter an der Cursor-Position einfügen */
|
||
function insertPlaceholder(tpl, key) {
|
||
const field = document.getElementById(`tpl-${tpl.id}`);
|
||
const current = tplDraft[tpl.id] ?? tpl.text;
|
||
const at = field?.selectionStart ?? current.length;
|
||
const next = `${current.slice(0, at)}{${key}}${current.slice(field?.selectionEnd ?? at)}`;
|
||
setTplDraft((old) => ({ ...old, [tpl.id]: next }));
|
||
// Cursor hinter den eingefügten Platzhalter setzen
|
||
requestAnimationFrame(() => {
|
||
if (!field) return;
|
||
field.focus();
|
||
const pos = at + key.length + 2;
|
||
field.setSelectionRange(pos, pos);
|
||
});
|
||
}
|
||
|
||
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */
|
||
function slugify(value) {
|
||
return String(value).toLowerCase()
|
||
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
|
||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||
.slice(0, 40);
|
||
}
|
||
|
||
async function savePageDraft() {
|
||
if (!pageDraft.title.trim() || !pageDraft.slug) {
|
||
flash('✗ Titel und Kürzel nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPut(`/api/pages/${pageDraft.slug}`, pageDraft);
|
||
setPages(res.pages.map((p) => ({
|
||
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
|
||
})));
|
||
setPageDraft({ ...pageDraft, isNew: false });
|
||
flash('✓ Seite gespeichert');
|
||
} catch (e) {
|
||
flash(e.status === 400 ? '✗ Kürzel ist reserviert oder ungültig' : '✗ Speichern fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function removePage(slug) {
|
||
if (!window.confirm(`Seite „/${slug}" wirklich löschen?`)) return;
|
||
const res = await apiDelete(`/api/pages/${slug}`).catch(() => null);
|
||
if (res) {
|
||
setPages(res.pages.map((p) => ({
|
||
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
|
||
})));
|
||
if (pageDraft.slug === slug) setPageDraft(emptyPage);
|
||
}
|
||
}
|
||
|
||
async function saveService() {
|
||
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
|
||
flash('✗ Name und URL nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const res = serviceDraft.id
|
||
? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft)
|
||
: await apiPost('/api/services', serviceDraft);
|
||
setServices(res.services);
|
||
setServiceDraft(emptyService);
|
||
flash('✓ Dienst gespeichert');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen (URL prüfen)');
|
||
}
|
||
}
|
||
|
||
async function removeService(id) {
|
||
const res = await apiDelete(`/api/services/${id}`).catch(() => null);
|
||
if (res) setServices(res.services);
|
||
}
|
||
|
||
async function createSsoApp() {
|
||
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
|
||
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPost('/api/ssoapps', ssoDraft);
|
||
setCreatedSso(res);
|
||
setSsoDraft({ slug: '', name: '', redirect_prefix: '' });
|
||
setSsoApps((await apiGet('/api/ssoapps')).apps);
|
||
flash('✓ App registriert');
|
||
} catch (e) {
|
||
flash(e.status === 409 ? '✗ Kürzel schon vergeben' : '✗ Registrierung fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function removeSsoApp(slug) {
|
||
if (!window.confirm(`App „${slug}" entfernen? Der Login über diese App funktioniert danach nicht mehr.`)) return;
|
||
const res = await apiDelete(`/api/ssoapps/${slug}`).catch(() => null);
|
||
if (res) setSsoApps(res.apps);
|
||
}
|
||
|
||
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
|
||
async function saveServer() {
|
||
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
|
||
flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig');
|
||
return;
|
||
}
|
||
try {
|
||
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
|
||
else await apiPost('/api/gameservers', serverDraft);
|
||
setServers((await apiGet('/api/gameservers')).servers);
|
||
setServerDraft(emptyServer);
|
||
flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen (URL mit http/https?)');
|
||
}
|
||
}
|
||
|
||
async function removeServer(id) {
|
||
if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return;
|
||
await apiDelete(`/api/gameservers/${id}`).catch(() => {});
|
||
setServers((await apiGet('/api/gameservers')).servers);
|
||
if (serverDraft.id === id) setServerDraft(emptyServer);
|
||
}
|
||
|
||
async function uploadAlphaKeys() {
|
||
if (!keyUpload.trim()) return;
|
||
try {
|
||
const res = await apiPost('/api/alphakeys', { keys: keyUpload });
|
||
setAlphaKeys(res);
|
||
setKeyUpload('');
|
||
flash(`✓ ${res.added} Keys hinzugefügt`);
|
||
} catch {
|
||
flash('✗ Upload fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function distributeKeys() {
|
||
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
|
||
try {
|
||
const res = await apiPost('/api/alphakeys/distribute');
|
||
setAlphaKeys(res);
|
||
flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`);
|
||
} catch {
|
||
flash('✗ Verteilung fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function saveForm(publish = false) {
|
||
const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean);
|
||
if (!formDraft.title.trim() || questions.length === 0) {
|
||
flash('✗ Titel und mindestens eine Frage nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const body = { ...formDraft, questions };
|
||
const res = formDraft.id
|
||
? await apiPut(`/api/appforms/${formDraft.id}`, body)
|
||
: await apiPost('/api/appforms', body);
|
||
if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`);
|
||
setAppForms((await apiGet('/api/appforms')).forms);
|
||
setFormDraft(emptyForm);
|
||
flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert');
|
||
} catch {
|
||
flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeForm(id) {
|
||
if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return;
|
||
await apiDelete(`/api/appforms/${id}`).catch(() => {});
|
||
setAppForms((await apiGet('/api/appforms')).forms);
|
||
if (formDraft.id === id) setFormDraft(emptyForm);
|
||
}
|
||
|
||
async function saveTriggerDraft() {
|
||
if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) {
|
||
flash('✗ Keyword (min 3 Zeichen) und Antwort nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPut('/api/triggers', triggerDraft);
|
||
setTriggers((await apiGet('/api/triggers')).triggers);
|
||
setTriggerDraft({ keyword: '', reply: '' });
|
||
flash('✓ Trigger gespeichert');
|
||
} catch {
|
||
flash('✗ Trigger fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function removeTrigger(keyword) {
|
||
await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {});
|
||
setTriggers((await apiGet('/api/triggers')).triggers);
|
||
}
|
||
|
||
async function uploadImage(target, file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
try {
|
||
await apiPost(`/api/branding/${target}`, { data: reader.result });
|
||
flash(`✓ ${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`);
|
||
} catch {
|
||
flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)');
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function saveBrand() {
|
||
try {
|
||
const body = { ...form, bot_name: botName, bot_description: botDesc };
|
||
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||
const res = await apiPut('/api/settings', body);
|
||
setForm(res.settings);
|
||
// Vergleichsstand mitziehen, sonst meldet die Leiste weiter
|
||
// „Änderungen offen", obwohl gerade gespeichert wurde
|
||
setData((d) => (d ? { ...d, settings: res.settings } : d));
|
||
setGiteaToken('');
|
||
setTwitchCreds({ id: '', secret: '' });
|
||
flash('✓ Gespeichert & angewendet');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)');
|
||
}
|
||
}
|
||
|
||
async function saveTagDraft() {
|
||
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
|
||
flash('✗ Name und Inhalt nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content });
|
||
setTags((await apiGet('/api/tags')).tags);
|
||
setTagDraft({ name: '', content: '' });
|
||
flash('✓ Tag gespeichert');
|
||
} catch {
|
||
flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)');
|
||
}
|
||
}
|
||
|
||
async function removeTag(name) {
|
||
if (!window.confirm(`Tag „${name}" löschen?`)) return;
|
||
await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {});
|
||
setTags((await apiGet('/api/tags')).tags);
|
||
}
|
||
|
||
async function createSchedule() {
|
||
if (!schedDraft.channel_id || !schedDraft.content.trim()) {
|
||
flash('✗ Kanal und Text nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPost('/api/scheduled', schedDraft);
|
||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||
setSchedDraft({ ...schedDraft, content: '', embed_title: '' });
|
||
flash('✓ Post geplant');
|
||
} catch {
|
||
flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeSchedule(id) {
|
||
await apiDelete(`/api/scheduled/${id}`).catch(() => {});
|
||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||
}
|
||
|
||
function flash(msg) {
|
||
setFeedback(msg);
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
|
||
// Welche Einstellungen weichen vom gespeicherten Stand ab? Der Brand-Tab
|
||
// pflegt Bot-Name und -Beschreibung außerhalb von `form` — die zählen mit,
|
||
// sonst meldet die Leiste „nichts geändert", während doch etwas offen ist.
|
||
const changedKeys = data?.settings && form
|
||
? Object.keys(form).filter((k) => String(form[k] ?? '') !== String(data.settings[k] ?? ''))
|
||
: [];
|
||
const brandChanged = Boolean(data) && (
|
||
botName !== (data.status.botName ?? '')
|
||
|| botDesc !== (data.status.botDescription ?? '')
|
||
|| giteaToken.trim() !== ''
|
||
|| twitchCreds.id.trim() !== '' || twitchCreds.secret.trim() !== ''
|
||
);
|
||
const dirtyCount = changedKeys.length + (brandChanged ? 1 : 0);
|
||
|
||
/** Alles zurück auf den gespeicherten Stand */
|
||
function discardChanges() {
|
||
setForm(data.settings);
|
||
setBotName(data.status.botName ?? '');
|
||
setBotDesc(data.status.botDescription ?? '');
|
||
setGiteaToken('');
|
||
setTwitchCreds({ id: '', secret: '' });
|
||
flash('Änderungen verworfen');
|
||
}
|
||
|
||
// Beim Verlassen mit offenen Änderungen nachfragen — vorher gingen sie
|
||
// kommentarlos verloren.
|
||
useEffect(() => {
|
||
if (dirtyCount === 0) return undefined;
|
||
const warn = (e) => { e.preventDefault(); e.returnValue = ''; };
|
||
window.addEventListener('beforeunload', warn);
|
||
return () => window.removeEventListener('beforeunload', warn);
|
||
}, [dirtyCount]);
|
||
|
||
/** Speichern aus der Leiste: saveBrand schickt das Formular mit und deckt
|
||
* damit beide Fälle ab, wenn auch Brand-Felder offen sind. */
|
||
const saveAll = () => (brandChanged ? saveBrand() : save());
|
||
|
||
async function save() {
|
||
try {
|
||
const body = { ...form };
|
||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||
const res = await apiPut('/api/settings', body);
|
||
setForm(res.settings);
|
||
// Vergleichsstand mitziehen, sonst meldet die Leiste weiter
|
||
// „Änderungen offen", obwohl gerade gespeichert wurde
|
||
setData((d) => (d ? { ...d, settings: res.settings } : d));
|
||
setTwitchCreds({ id: '', secret: '' });
|
||
flash('✓ Gespeichert');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function sendTest(target) {
|
||
try {
|
||
await apiPost(`/api/settings/test/${target}`);
|
||
flash('✓ Test-Nachricht gesendet');
|
||
} catch {
|
||
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
|
||
}
|
||
}
|
||
|
||
async function createKey() {
|
||
if (!newKeyName.trim() || newKeyScopes.length === 0) return;
|
||
try {
|
||
const res = await apiPost('/api/apikeys', { name: newKeyName.trim(), scopes: newKeyScopes });
|
||
setCreatedKey(res);
|
||
setNewKeyName('');
|
||
setKeys((await apiGet('/api/apikeys')).keys);
|
||
} catch {
|
||
flash('✗ Key-Erstellung fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function revokeKey(id) {
|
||
if (!window.confirm('Diesen API-Key widerrufen? Skripte mit diesem Key verlieren sofort den Zugriff.')) return;
|
||
await apiDelete(`/api/apikeys/${id}`).catch(() => {});
|
||
setKeys((await apiGet('/api/apikeys')).keys);
|
||
if (createdKey?.id === id) setCreatedKey(null);
|
||
}
|
||
|
||
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
|
||
|
||
async function saveMenu(publish = false) {
|
||
const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id);
|
||
if (!menuDraft.title.trim() || entries.length === 0) {
|
||
flash('✗ Titel und mindestens eine Rolle nötig');
|
||
return;
|
||
}
|
||
if (publish && !menuDraft.channel_id) {
|
||
flash('✗ Zum Posten einen Kanal wählen');
|
||
return;
|
||
}
|
||
try {
|
||
const body = { ...menuDraft, entries };
|
||
const res = menuDraft.id
|
||
? await apiPut(`/api/rolemenus/${menuDraft.id}`, body)
|
||
: await apiPost('/api/rolemenus', body);
|
||
let menu = res.menu;
|
||
if (publish) {
|
||
await apiPost(`/api/rolemenus/${menu.id}/publish`);
|
||
flash('✓ Menü in Discord gepostet');
|
||
} else {
|
||
flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert');
|
||
}
|
||
await reloadMenus();
|
||
setMenuDraft(emptyMenu);
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen — Einträge prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeMenu(id) {
|
||
if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return;
|
||
await apiDelete(`/api/rolemenus/${id}`).catch(() => {});
|
||
if (menuDraft.id === id) setMenuDraft(emptyMenu);
|
||
await reloadMenus();
|
||
}
|
||
|
||
const editMenu = (m) =>
|
||
setMenuDraft({
|
||
id: m.id, title: m.title, description: m.description,
|
||
channel_id: m.channel_id ?? '', exclusive: m.exclusive,
|
||
entries: m.entries.length
|
||
? m.entries.map((e) => ({ style: 'secondary', ...e }))
|
||
: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
|
||
});
|
||
|
||
const setMenuEntry = (i, patch) =>
|
||
setMenuDraft({
|
||
...menuDraft,
|
||
entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)),
|
||
});
|
||
|
||
const header = (
|
||
<section className="page-header">
|
||
<div className="page-bg-text">CONFIG</div>
|
||
<div className="page-header-grid" aria-hidden="true" />
|
||
<div className="page-header-content">
|
||
<div className="page-tag">// Admin</div>
|
||
<h1 className="page-title">Config</h1>
|
||
<p className="page-subtitle">Alles an einem Ort — Änderungen greifen sofort.</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
|
||
return (
|
||
<>
|
||
{header}
|
||
<section className="content">
|
||
<div className="notice center">
|
||
<p className="with-icon"><IconLock /> Nur für den Admin.</p>
|
||
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
|
||
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||
|
||
const channelItems = data.channels.map((c) => ({ id: c.id, label: `#${c.name}`, hint: c.guild }));
|
||
const roleItems = (data.roles ?? []).map((r) => ({ id: r.id, label: `@${r.name}`, hint: r.guild }));
|
||
// Der Composer bringt seine eigene Auswahl mit und erwartet <option>-Elemente
|
||
const channelOptions = channelItems.map((c) => (
|
||
<option key={c.id} value={c.id}>{c.label} — {c.hint}</option>
|
||
));
|
||
const channelName = (id) => {
|
||
const c = data.channels.find((x) => x.id === id);
|
||
return c ? `#${c.name}` : id;
|
||
};
|
||
|
||
/* ── Tab-Inhalte ───────────────────────────────── */
|
||
|
||
// Eingeschaltete Module, denen noch etwas fehlt — die tun bis dahin nichts
|
||
const activeModules = modules.filter((m) => m.enabled);
|
||
const unfinished = activeModules.filter((m) => m.missing.length > 0);
|
||
|
||
/** Wie alt ist ein Zeitstempel, in Tagen — fuer die Nebenzeilen */
|
||
function alterInTagen(wert) {
|
||
const d = alsDatum(wert);
|
||
if (!d) return null;
|
||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||
}
|
||
|
||
/** „vor 3 Tagen" bzw. „heute" fuer eine Nebenzeile */
|
||
function seit(iso, nie) {
|
||
const t = alterInTagen(iso);
|
||
if (t == null) return nie;
|
||
if (t <= 0) return 'heute';
|
||
return t === 1 ? 'gestern' : `vor ${t} Tagen`;
|
||
}
|
||
|
||
// Mitglieder-Zuwachs der letzten 7 Tage aus den Tagesdaten
|
||
const memberDelta = serverStats?.days
|
||
? serverStats.days.slice(-7).reduce((n, d) => n + (d.joins ?? 0) - (d.leaves ?? 0), 0)
|
||
: null;
|
||
|
||
// Nachrichten der letzten 30 Tage, luckenlos — fehlende Tage sind 0 und
|
||
// nicht einfach weg, sonst staucht sich der Verlauf
|
||
const aktivitaet = (() => {
|
||
if (!serverStats?.days) return null;
|
||
const proTag = new Map(serverStats.days.map((d) => [d.day, d.messages ?? 0]));
|
||
const tage = [];
|
||
for (let i = 29; i >= 0; i--) {
|
||
const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
|
||
tage.push({ key, n: proTag.get(key) ?? 0 });
|
||
}
|
||
return { tage, gesamt: tage.reduce((n, t) => n + t.n, 0), max: Math.max(1, ...tage.map((t) => t.n)) };
|
||
})();
|
||
|
||
const backupTage = alterInTagen(data.status.lastBackup);
|
||
|
||
const tabStatus = (
|
||
<>
|
||
<div className="settings-section">
|
||
<div className="dash-head">
|
||
{data.status.botAvatar
|
||
? <img className="dash-avatar" src={data.status.botAvatar} alt="" />
|
||
: <span className="dash-avatar leer">?</span>}
|
||
<div className="dash-ident">
|
||
<span className="dash-name">
|
||
{data.status.botName ?? 'Bot'}
|
||
{data.status.botTag && <span className="dash-tag">{data.status.botTag}</span>}
|
||
</span>
|
||
<span className="dash-pill"><span className="dash-dot" /> online</span>
|
||
</div>
|
||
<span className="dash-uptime">
|
||
seit {fmtUptime(data.status.uptimeSeconds)}
|
||
{data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="dash-kpis">
|
||
<div className="card dash-kpi">
|
||
<span className="dash-v">{activeModules.length}<span className="dash-of">/{modules.length || '–'}</span></span>
|
||
<span className="dash-l">Module an</span>
|
||
<span className={`dash-t${unfinished.length > 0 ? ' warn' : ' gut'}`}>
|
||
{unfinished.length > 0 ? `${unfinished.length} unvollständig` : 'alle einsatzbereit'}
|
||
</span>
|
||
</div>
|
||
<div className="card dash-kpi">
|
||
<span className="dash-v">{serverStats?.members != null ? serverStats.members.toLocaleString('de-DE') : '—'}</span>
|
||
<span className="dash-l">Member</span>
|
||
<span className={`dash-t${memberDelta > 0 ? ' gut' : ''}`}>
|
||
{memberDelta == null
|
||
? ' '
|
||
: `${memberDelta >= 0 ? '+' : ''}${memberDelta} in 7 Tagen`}
|
||
</span>
|
||
</div>
|
||
<div className="card dash-kpi">
|
||
<span className="dash-v">{data.status.devlogs}</span>
|
||
<span className="dash-l">Devlogs</span>
|
||
<span className="dash-t">{seit(data.status.lastDevlog, 'noch keins')}</span>
|
||
</div>
|
||
<div className="card dash-kpi">
|
||
<span className="dash-v">{fmtBytes(data.status.dbSizeBytes)}</span>
|
||
<span className="dash-l">Datenbank</span>
|
||
<span className={`dash-t${backupTage == null || backupTage > 2 ? ' warn' : ' gut'}`}>
|
||
Backup {seit(data.status.lastBackup, 'fehlt')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{aktivitaet && aktivitaet.gesamt > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">
|
||
// Aktivität — {aktivitaet.gesamt.toLocaleString('de-DE')} Nachrichten in 30 Tagen
|
||
</h2>
|
||
<div className="dash-spark">
|
||
{aktivitaet.tage.map((t) => (
|
||
<span
|
||
key={t.key}
|
||
className="dash-bar"
|
||
style={{ height: `${Math.max(3, (t.n / aktivitaet.max) * 100)}%` }}
|
||
title={`${t.key}: ${t.n} Nachrichten`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{modules.length > 0 && moduleGroups.length > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Module nach Bereich</h2>
|
||
<div className="dash-groups">
|
||
{moduleGroups.map((g) => {
|
||
const drin = modules.filter((m) => m.group === g.id);
|
||
if (drin.length === 0) return null;
|
||
const an = drin.filter((m) => m.enabled).length;
|
||
const offen = drin.filter((m) => m.enabled && m.missing.length > 0).length;
|
||
return (
|
||
<button
|
||
className="dash-group"
|
||
key={g.id}
|
||
onClick={() => setTab('module')}
|
||
title={`${g.label} im Modul-Überblick öffnen`}
|
||
>
|
||
<span className="dash-group-name">{g.label}</span>
|
||
<span className={`dash-group-bar${offen > 0 ? ' warn' : ''}`}>
|
||
<i style={{ width: `${(an / drin.length) * 100}%` }} />
|
||
</span>
|
||
<span className="dash-group-n">{an}/{drin.length}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{modules.length > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Noch einzurichten</h2>
|
||
{unfinished.length === 0 ? (
|
||
<p className="section-intro todo-done with-icon">
|
||
<IconCheck size={15} /> Alles eingerichtet — jedes eingeschaltete Modul hat, was es braucht.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="section-intro">
|
||
Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas —
|
||
bis dahin tun sie nichts. Einzeln anlegen, oder alles auf einmal.
|
||
</p>
|
||
|
||
{/* Grundgerüst: erst Vorschau, dann anlegen */}
|
||
{!plan && (
|
||
<div className="settings-actions" style={{ marginBottom: '1.1rem' }}>
|
||
<button className="btn" onClick={loadPlan}>
|
||
Alles auf einmal anlegen …
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{plan && (
|
||
<div className="setup-plan">
|
||
<p className="setup-plan-intro">
|
||
{plan.schritte.filter((s) => !s.vorhanden).length} werden neu angelegt
|
||
{plan.schritte.some((s) => s.vorhanden)
|
||
&& ` · ${plan.schritte.filter((s) => s.vorhanden).length} sind schon da und werden nur verknüpft`}
|
||
. Neue Kanäle landen in der Kategorie <strong>{plan.kategorie}</strong>.
|
||
</p>
|
||
<div className="setup-plan-list">
|
||
{plan.schritte.map((s) => (
|
||
<div className={`setup-step${s.vorhanden ? ' da' : ''}`} key={s.setting}>
|
||
<span className="setup-step-name">
|
||
{s.art === 'rolle' ? '@' : '#'}{s.name}
|
||
</span>
|
||
<span className="setup-step-note">
|
||
{s.vorhanden ? 'vorhanden — wird verknüpft' : s.privat ? 'neu, privat' : 'neu'}
|
||
</span>
|
||
<span className="setup-step-for">{s.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" disabled={setupRunning} onClick={runSetup}>
|
||
{setupRunning ? 'Legt an …' : 'Jetzt anlegen'}
|
||
</button>
|
||
<button className="btn btn-ghost" disabled={setupRunning} onClick={() => setPlan(null)}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{setupResult && (
|
||
<div className="setup-plan">
|
||
<p className="setup-plan-intro">Ergebnis:</p>
|
||
<div className="setup-plan-list">
|
||
{setupResult.map((r) => (
|
||
<div className={`setup-step${r.ok ? '' : ' fehler'}`} key={r.setting}>
|
||
<span className="setup-step-name">{r.name ?? r.label}</span>
|
||
<span className="setup-step-note">
|
||
{r.ok
|
||
? (r.wiederverwendet ? 'verknüpft' : 'angelegt')
|
||
: r.fehler}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-ghost" onClick={() => setSetupResult(null)}>Schließen</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="key-list">
|
||
{unfinished.map((m) => (
|
||
<div className="key-row" key={m.id}>
|
||
<span className="key-name">{m.name}</span>
|
||
<span className="todo-missing with-icon">
|
||
<IconWarning size={13} /> {m.missing.map((x) => x.label).join(', ')}
|
||
</span>
|
||
{/* Was der Bot selbst anlegen kann, bekommt einen Knopf dafür */}
|
||
{m.missing.filter((x) => x.create).map((x) => (
|
||
<button
|
||
key={x.setting}
|
||
className="btn-mini btn-create"
|
||
disabled={creating === x.setting}
|
||
title={`#${x.create.name} anlegen und eintragen`}
|
||
onClick={() => createMissing(x)}
|
||
>
|
||
{creating === x.setting
|
||
? '…'
|
||
: `${x.create.rolle ? 'Rolle' : 'Kanal'} anlegen`}
|
||
</button>
|
||
))}
|
||
<button
|
||
className="btn-mini"
|
||
title={`Zum Feld „${m.missing[0].label}" springen`}
|
||
onClick={() => jumpToMissing(m.missing[0], m.tab)}
|
||
>
|
||
Einrichten
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
||
const tabBrand = (
|
||
<>
|
||
<div className="settings-section bot-card">
|
||
<div className="bot-card-left">
|
||
<div className="bot-avatar-wrap">
|
||
{data.status.botAvatar
|
||
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
|
||
: <div className="bot-avatar bot-avatar-empty"><IconBot size={30} /></div>}
|
||
<span className="bot-dot" style={{ background: statusDot }} />
|
||
</div>
|
||
<label className="btn btn-upload">
|
||
<IconUpload size={14} /> Bild ändern
|
||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||
</label>
|
||
<label className="btn btn-upload btn-ghost">
|
||
<IconUpload size={14} /> Banner
|
||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||
</label>
|
||
</div>
|
||
<div className="bot-card-right">
|
||
<div className="field" data-setting="bot_name">
|
||
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Beschreibung <span className="field-hint">„Über mich" im Bot-Profil (max 400 Zeichen)</span></label>
|
||
<textarea rows={3} maxLength={400} placeholder="z. B. Community-Bot für EcoGame — Devlogs, Level, Tickets & mehr. 🤖" value={botDesc} onChange={(e) => setBotDesc(e.target.value)} />
|
||
</div>
|
||
<div className="field" data-setting="bot_presence_status">
|
||
<label>Bot-Status</label>
|
||
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||
<option value="online">🟢 Online</option>
|
||
<option value="idle">🌙 Abwesend</option>
|
||
<option value="dnd">⛔ Bitte nicht stören</option>
|
||
</select>
|
||
</div>
|
||
<div className="field" data-setting="bot_status_text">
|
||
<label>Aktivität <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
||
<div className="field-row">
|
||
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
||
<option value="custom">Status</option>
|
||
<option value="playing">Spielt</option>
|
||
<option value="watching">Schaut</option>
|
||
<option value="listening">Hört</option>
|
||
</select>
|
||
<input type="text" placeholder="z. B. ⛏️ EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Embeds</h2>
|
||
<div className="field" data-setting="brand_name">
|
||
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
|
||
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="brand_color">
|
||
<label>Embed-Farben <span className="field-hint">primär (Embeds) · sekundär (Releases, Mod-Aktionen)</span></label>
|
||
<div className="field-row">
|
||
<input type="color" className="color-pick" value={form.brand_color} onChange={(e) => setForm({ ...form, brand_color: e.target.value })} />
|
||
<input type="color" className="color-pick" value={form.brand_color2} onChange={(e) => setForm({ ...form, brand_color2: e.target.value })} />
|
||
<span className="field-hint" style={{ alignSelf: 'center' }}>{form.brand_color} · {form.brand_color2}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Aus der Env verlagert</h2>
|
||
<div className="field" data-setting="discord_guild_id">
|
||
<label>Guild-ID <span className="field-hint">für sofortige Slash-Commands; greift beim nächsten Neustart</span></label>
|
||
<input type="text" placeholder="Server-ID" value={form.discord_guild_id} onChange={(e) => setForm({ ...form, discord_guild_id: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="gitea_api_token">
|
||
<label>Gitea-API-Token <span className="field-hint">{form.gitea_api_token_set ? '✓ gesetzt — neuen Wert eingeben zum Ersetzen' : 'für /bug; wird nie wieder angezeigt'}</span></label>
|
||
<input type="password" placeholder="Token (write-only)" value={giteaToken} onChange={(e) => setGiteaToken(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={saveBrand}>Speichern & anwenden</button>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabFeeds = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Kanäle</h2>
|
||
<div className="field" data-setting="devlog_channel_id">
|
||
<label>Devlog-Kanal <span className="field-hint">öffentlich — hier postet der Bot die Devlogs</span></label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.devlog_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, devlog_channel_id: v })}
|
||
empty="— Kanal wählen —"
|
||
/>
|
||
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="commit_channel_id">
|
||
<label>Commit-Kanal <span className="field-hint">privat — hier landen die Push-Embeds</span></label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.commit_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, commit_channel_id: v })}
|
||
empty="— Kanal wählen —"
|
||
/>
|
||
<button className="btn" onClick={() => sendTest('commit')}>Test</button>
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="release_channel_id">
|
||
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases — leer = aus</span></label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.release_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, release_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
<button className="btn" onClick={() => sendTest('release')}>Test</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Devlog</h2>
|
||
<div className="field" data-setting="devlog_ping_role_id">
|
||
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post — leer = kein Ping</span></label>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={form.devlog_ping_role_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, devlog_ping_role_id: v })}
|
||
empty="— kein Ping —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="weekly_recap_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.weekly_recap_enabled} onChange={(e) => setForm({ ...form, weekly_recap_enabled: e.target.checked })} />
|
||
<span>Wochen-Rückblick (sonntags 20:00)</span>
|
||
<span className="field-hint">Commits pro Tag, Devlogs & Top-Projekte</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="devlog_threads_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.devlog_threads_enabled} onChange={(e) => setForm({ ...form, devlog_threads_enabled: e.target.checked })} />
|
||
<span>Diskussions-Thread unter jedem Devlog</span>
|
||
<span className="field-hint">„💬 Devlog <Datum>"</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<button className="btn" onClick={() => sendTest('recap')}>Rückblick jetzt testen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Commit-Feed</h2>
|
||
<div className="field" data-setting="commit_feed_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.commit_feed_enabled} onChange={(e) => setForm({ ...form, commit_feed_enabled: e.target.checked })} />
|
||
<span>Push-Embeds posten</span>
|
||
<span className="field-hint">aus = weiter archivieren, nur nicht posten</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="commit_branch_filter">
|
||
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle</span></label>
|
||
<input type="text" placeholder="z. B. main, release" value={form.commit_branch_filter} onChange={(e) => setForm({ ...form, commit_branch_filter: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="ignored_repos">
|
||
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen</span></label>
|
||
<input type="text" placeholder="z. B. D4rkst3r/d4rkbot" value={form.ignored_repos} onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabCommunity = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Geburtstage</h2>
|
||
<div className="field" data-setting="birthday_channel_id">
|
||
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.birthday_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, birthday_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="birthday_role_id">
|
||
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={form.birthday_role_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, birthday_role_id: v })}
|
||
empty="— keine Rolle —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Playtester</h2>
|
||
<div className="field" data-setting="playtester_role_id">
|
||
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={form.playtester_role_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
{playtesters.length > 0 && (
|
||
<div className="key-list">
|
||
{playtesters.map((p) => (
|
||
<div className="key-row" key={p.user_id}>
|
||
<span className="key-name">{p.username}</span>
|
||
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Alpha-Keys</h2>
|
||
<div className="stats" style={{ marginBottom: '1.2rem' }}>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Frei</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.assigned?.length ?? '—'}</span><span className="stat-label">Vergeben</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Keys hinzufügen <span className="field-hint">einer pro Zeile</span></label>
|
||
<textarea rows={3} placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'} value={keyUpload} onChange={(e) => setKeyUpload(e.target.value)} />
|
||
</div>
|
||
<div className="field-row">
|
||
<button className="btn" onClick={uploadAlphaKeys}>Hinzufügen</button>
|
||
<button
|
||
className="btn btn-save"
|
||
onClick={distributeKeys}
|
||
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||
>
|
||
<IconKey size={14} /> An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
|
||
</button>
|
||
</div>
|
||
{alphaKeys?.assigned?.length > 0 && (
|
||
<div className="key-list" style={{ marginTop: '1rem' }}>
|
||
{alphaKeys.assigned.map((k) => (
|
||
<div className="key-row" key={k.key}>
|
||
<span className="key-name">{k.key}</span>
|
||
<span className="key-used">→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Starboard & Galerie</h2>
|
||
<div className="field" data-setting="starboard_channel_id">
|
||
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.starboard_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, starboard_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
<input type="text" style={{ width: '6rem', flex: 'none' }} title="Schwellwert (Anzahl ⭐)" value={form.starboard_threshold} onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="screenshot_channel_id">
|
||
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.screenshot_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, screenshot_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="voting_channel_id">
|
||
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.voting_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, voting_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Level-System</h2>
|
||
<div className="field" data-setting="levels_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.levels_enabled} onChange={(e) => setForm({ ...form, levels_enabled: e.target.checked })} />
|
||
<span>XP fürs Schreiben vergeben</span>
|
||
<span className="field-hint">15–25 XP pro Nachricht, 60s Cooldown; /rank + Bestenliste auf /level</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="levels_announce">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.levels_announce} onChange={(e) => setForm({ ...form, levels_announce: e.target.checked })} />
|
||
<span>Level-Up im Kanal feiern</span>
|
||
<span className="field-hint">„🎉 Name ist jetzt Level 5!"</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="level_rewards">
|
||
<label>Rollen-Belohnungen <span className="field-hint">Format: Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678</span></label>
|
||
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabRollen = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Rollen bei Join</h2>
|
||
<div className="field" data-setting="autorole_id">
|
||
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch — leer = aus</span></label>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={form.autorole_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, autorole_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="sticky_roles_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.sticky_roles_enabled} onChange={(e) => setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
|
||
<span>Sticky-Roles</span>
|
||
<span className="field-hint">wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder</span>
|
||
</label>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||
<p className="section-intro">
|
||
Selbstbedienungs-Rollen wie bei Carl-bot — nur ohne Paywall. Der Bot postet
|
||
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
|
||
jederzeit editierbar, der Discord-Post zieht automatisch mit.
|
||
</p>
|
||
|
||
{menus.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{menus.map((m) => (
|
||
<div className="key-row" key={m.id}>
|
||
<span className="key-name">{m.title}</span>
|
||
<span className="key-scopes">
|
||
{m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
|
||
{m.exclusive ? ' · exklusiv' : ''}
|
||
</span>
|
||
<span className="key-used">
|
||
{m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
|
||
</span>
|
||
<button className="btn btn-mini" onClick={() => editMenu(m)}>Bearbeiten</button>
|
||
<button className="card-delete" title="Menü + Discord-Post löschen" onClick={() => removeMenu(m.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="Titel, z. B. 🎭 Hol dir deine Rollen" value={menuDraft.title} onChange={(e) => setMenuDraft({ ...menuDraft, title: e.target.value })} />
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={menuDraft.channel_id}
|
||
onChange={(v) => setMenuDraft({ ...menuDraft, channel_id: v })}
|
||
empty="— Kanal wählen —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Beschreibung <span className="field-hint">optional, steht im Embed über den Buttons</span></label>
|
||
<input type="text" placeholder="z. B. Wähl aus, wofür du Pings bekommen willst" value={menuDraft.description} onChange={(e) => setMenuDraft({ ...menuDraft, description: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={menuDraft.exclusive} onChange={(e) => setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
|
||
<span>Exklusiv</span>
|
||
<span className="field-hint">max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label>Rollen <span className="field-hint">Emoji optional (auch Custom als <:name:id>), Label = Button-Text</span></label>
|
||
{menuDraft.entries.map((entry, i) => (
|
||
<div className="rr-row" key={i}>
|
||
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
|
||
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={entry.role_id}
|
||
onChange={(v) => setMenuEntry(i, { role_id: v })}
|
||
empty="— Rolle —"
|
||
/>
|
||
<select
|
||
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
|
||
title="Button-Farbe"
|
||
value={entry.style ?? 'secondary'}
|
||
onChange={(e) => setMenuEntry(i, { style: e.target.value })}
|
||
>
|
||
<option value="secondary">⬜ Grau</option>
|
||
<option value="primary">🟦 Blau</option>
|
||
<option value="success">🟩 Grün</option>
|
||
<option value="danger">🟥 Rot</option>
|
||
</select>
|
||
<button
|
||
className="card-delete"
|
||
title="Zeile entfernen"
|
||
onClick={() => setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
|
||
disabled={menuDraft.entries.length === 1}
|
||
>✕</button>
|
||
</div>
|
||
))}
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn" onClick={() => setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
|
||
+ Rolle
|
||
</button>
|
||
<button className="btn" onClick={() => saveMenu(false)}>Speichern</button>
|
||
<button className="btn btn-save" onClick={() => saveMenu(true)}>Speichern + posten</button>
|
||
{menuDraft.id && (
|
||
<button className="btn btn-ghost" onClick={() => setMenuDraft(emptyMenu)}>Abbrechen</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
/** AutoMod-Regel an- oder abschalten */
|
||
async function automodSchalten(id, aktiv) {
|
||
try {
|
||
await apiPut(`/api/automod/${id}`, { aktiv });
|
||
const frisch = await apiGet('/api/automod');
|
||
setAutomod(frisch);
|
||
} catch (e) {
|
||
setFeedback(e.body?.error ?? 'Umschalten fehlgeschlagen.');
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
}
|
||
|
||
const tabSupport = (
|
||
<>
|
||
{automod && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// AutoMod</h2>
|
||
<p className="section-intro">
|
||
Discords eigener Filter — er läuft serverseitig und ohne Umweg über den Bot.
|
||
Hier siehst du, was eingerichtet ist, und kannst Regeln schalten.
|
||
Wortlisten bearbeitest du in Discord unter <strong>Servereinstellungen → AutoMod</strong>.
|
||
{!automod.aktiv && ' Das Protokollieren der Treffer ist gerade ausgeschaltet.'}
|
||
</p>
|
||
|
||
{!automod.ok && (
|
||
<p className="notice">
|
||
Regeln nicht abrufbar: {automod.grund}
|
||
{' '}— fehlt dem Bot das Recht „Server verwalten"?
|
||
</p>
|
||
)}
|
||
{automod.ok && automod.regeln.length === 0 && (
|
||
<p className="notice">
|
||
Noch keine AutoMod-Regel eingerichtet. Anlegen geht in Discord unter
|
||
{' '}<strong>Servereinstellungen → AutoMod</strong>.
|
||
</p>
|
||
)}
|
||
|
||
{automod.regeln.map((r) => (
|
||
<div className="am-row card" key={r.id}>
|
||
<div className="am-head">
|
||
<span className={`wd-dot ${r.aktiv ? 'an' : 'wartet'}`} />
|
||
<span className="st-name">{r.name}</span>
|
||
<span className="tm-chip">{r.ausloeser}</span>
|
||
<label className="toggle am-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={r.aktiv}
|
||
onChange={(e) => automodSchalten(r.id, e.target.checked)}
|
||
/>
|
||
<span>{r.aktiv ? 'aktiv' : 'aus'}</span>
|
||
</label>
|
||
</div>
|
||
<div className="am-meta">
|
||
{r.details.length > 0 && <span>{r.details.join(' · ')}</span>}
|
||
<span className="am-actions">
|
||
{r.aktionen.map((a) => <span className="tm-chip" key={a}>{a}</span>)}
|
||
</span>
|
||
{(r.ausnahmen.rollen > 0 || r.ausnahmen.kanaele > 0) && (
|
||
<span className="am-exempt">
|
||
ausgenommen: {[
|
||
r.ausnahmen.rollen > 0 && `${r.ausnahmen.rollen} ${r.ausnahmen.rollen === 1 ? 'Rolle' : 'Rollen'}`,
|
||
r.ausnahmen.kanaele > 0 && `${r.ausnahmen.kanaele} ${r.ausnahmen.kanaele === 1 ? 'Kanal' : 'Kanäle'}`,
|
||
].filter(Boolean).join(', ')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Moderation & Kontakt</h2>
|
||
<div className="field" data-setting="modmail_channel_id">
|
||
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.modmail_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, modmail_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="ticket_channel_id">
|
||
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.ticket_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, ticket_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="welcome_channel_id">
|
||
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.welcome_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="modlog_channel_id">
|
||
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.modlog_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, modlog_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Willkommens-Karte</h2>
|
||
<p className="section-intro">
|
||
Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
|
||
zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
|
||
</p>
|
||
|
||
<div className="field" data-setting="welcome_card_enabled">
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.welcome_card_enabled}
|
||
onChange={(e) => setForm({ ...form, welcome_card_enabled: e.target.checked })}
|
||
/>
|
||
<span>Karte rendern</span>
|
||
<span className="field-hint">aus = nur das Text-Embed mit Avatar als Vorschaubild</span>
|
||
</label>
|
||
</div>
|
||
|
||
{form.welcome_card_enabled && (
|
||
<>
|
||
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
|
||
|
||
<div className="wc-colors">
|
||
{[
|
||
['welcome_card_bg', 'Hintergrund'],
|
||
['welcome_card_accent', 'Akzent 1'],
|
||
['welcome_card_accent2', 'Akzent 2'],
|
||
['welcome_card_text', 'Name'],
|
||
].map(([key, label]) => (
|
||
<label className="wc-color" key={key}>
|
||
<input
|
||
className="color-pick"
|
||
type="color"
|
||
value={form[key] ?? '#000000'}
|
||
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||
/>
|
||
<span>{label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<div className="field" data-setting="welcome_card_kicker">
|
||
<label>Kleine Zeile <span className="field-hint">über dem Namen — leer = weglassen</span></label>
|
||
<input
|
||
type="text" maxLength={24} placeholder="WILLKOMMEN"
|
||
value={form.welcome_card_kicker ?? ''}
|
||
onChange={(e) => setForm({ ...form, welcome_card_kicker: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="welcome_card_sub">
|
||
<label>Zeile darunter <span className="field-hint">{'{count}'} wird zur Mitglieds-Nummer — leer = weglassen</span></label>
|
||
<input
|
||
type="text" maxLength={60} placeholder="Member #{count}"
|
||
value={form.welcome_card_sub ?? ''}
|
||
onChange={(e) => setForm({ ...form, welcome_card_sub: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="welcome_card_image">
|
||
<label>Hintergrundbild <span className="field-hint">https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster</span></label>
|
||
<input
|
||
type="text" placeholder="https://cdn.d4rkst3r.de/welcome.png"
|
||
value={form.welcome_card_image ?? ''}
|
||
onChange={(e) => setForm({ ...form, welcome_card_image: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="welcome_card_watermark">
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.welcome_card_watermark}
|
||
onChange={(e) => setForm({ ...form, welcome_card_watermark: e.target.checked })}
|
||
/>
|
||
<span>Markenname im Hintergrund</span>
|
||
<span className="field-hint">großer, schwach sichtbarer Schriftzug hinten rechts</span>
|
||
</label>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabBewerbungen = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Bewerbungs-Formulare</h2>
|
||
<p className="section-intro">
|
||
Button-Post → Discord-Formular (bis 5 Fragen) → Review mit ✅/❌ im Staff-Kanal.
|
||
Annahme vergibt optional eine Rolle — z. B. für FiveM-Whitelist oder Team-Bewerbungen.
|
||
</p>
|
||
|
||
{appForms.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{appForms.map((f) => (
|
||
<div className="key-row" key={f.id}>
|
||
<span className="key-name">{f.title}</span>
|
||
<span className="key-scopes">{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}</span>
|
||
<button className="btn btn-mini" onClick={() => setFormDraft({
|
||
id: f.id, title: f.title, description: f.description,
|
||
review_channel_id: f.review_channel_id ?? '', approve_role_id: f.approve_role_id ?? '',
|
||
post_channel_id: f.post_channel_id ?? '',
|
||
questions: f.questions.length ? f.questions : [''],
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Formular + Post löschen" onClick={() => removeForm(f.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{formDraft.id ? `Formular #${formDraft.id} bearbeiten` : 'Neues Formular'}</label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="Titel, z. B. 🎮 FiveM-Whitelist" value={formDraft.title} onChange={(e) => setFormDraft({ ...formDraft, title: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<input type="text" placeholder="Beschreibung überm Button (optional)" value={formDraft.description} onChange={(e) => setFormDraft({ ...formDraft, description: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={formDraft.post_channel_id}
|
||
onChange={(v) => setFormDraft({ ...formDraft, post_channel_id: v })}
|
||
empty="— Post-Kanal (öffentlich) —"
|
||
/>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={formDraft.review_channel_id}
|
||
onChange={(v) => setFormDraft({ ...formDraft, review_channel_id: v })}
|
||
empty="— Review-Kanal (Staff) —"
|
||
/>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={formDraft.approve_role_id}
|
||
onChange={(v) => setFormDraft({ ...formDraft, approve_role_id: v })}
|
||
empty="— Rolle bei Annahme —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Fragen <span className="field-hint">max 5 (Discord-Modal-Limit)</span></label>
|
||
{formDraft.questions.map((q, i) => (
|
||
<div className="rr-row" key={i}>
|
||
<input type="text" placeholder={`Frage ${i + 1}`} maxLength={45} value={q} onChange={(e) => setFormDraft({
|
||
...formDraft,
|
||
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
|
||
})} />
|
||
<button className="card-delete" title="Frage entfernen" disabled={formDraft.questions.length === 1}
|
||
onClick={() => setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}>✕</button>
|
||
</div>
|
||
))}
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn" disabled={formDraft.questions.length >= 5}
|
||
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage</button>
|
||
<button className="btn" onClick={() => saveForm(false)}>Speichern</button>
|
||
<button className="btn btn-save" onClick={() => saveForm(true)}>Speichern + posten</button>
|
||
{formDraft.id && <button className="btn btn-ghost" onClick={() => setFormDraft(emptyForm)}>Abbrechen</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const tabComposer = (
|
||
<>
|
||
<EmbedComposer channelOptions={channelOptions} flash={flash} />
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Geplante Posts</h2>
|
||
{scheduled.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{scheduled.map((p) => (
|
||
<div className="key-row" key={p.id}>
|
||
<span className="key-name">{channelName(p.channel_id)}</span>
|
||
<span className="key-scopes">
|
||
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
|
||
{p.enabled ? '' : ' · erledigt'}
|
||
</span>
|
||
<span className="key-used" title={p.content}>{p.content.slice(0, 40)}…</span>
|
||
<button className="card-delete" title="Löschen" onClick={() => removeSchedule(p.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Neuer geplanter Post</label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={schedDraft.channel_id}
|
||
onChange={(v) => setSchedDraft({ ...schedDraft, channel_id: v })}
|
||
empty="— Kanal wählen —"
|
||
/>
|
||
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
|
||
<option value="once">einmalig</option>
|
||
<option value="daily">täglich</option>
|
||
<option value="weekly">wöchentlich</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
{schedDraft.type === 'once' && (
|
||
<input type="date" value={schedDraft.date} onChange={(e) => setSchedDraft({ ...schedDraft, date: e.target.value })} />
|
||
)}
|
||
{schedDraft.type === 'weekly' && (
|
||
<select value={schedDraft.weekday} onChange={(e) => setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
|
||
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
|
||
<option key={i} value={String(i)}>{d}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<input type="time" value={schedDraft.time} onChange={(e) => setSchedDraft({ ...schedDraft, time: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<input type="text" placeholder="Embed-Titel (leer = normale Nachricht)" value={schedDraft.embed_title} onChange={(e) => setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<textarea rows={4} placeholder="Text (Discord-Markdown)" value={schedDraft.content} onChange={(e) => setSchedDraft({ ...schedDraft, content: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<button className="btn btn-save" onClick={createSchedule}>Planen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Triggers</h2>
|
||
<p className="section-intro">
|
||
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
|
||
</p>
|
||
{triggers.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{triggers.map((t) => (
|
||
<div className="key-row" key={t.keyword}>
|
||
<span className="key-name">{t.keyword}</span>
|
||
<span className="key-used" title={t.reply}>{t.reply.slice(0, 50)}</span>
|
||
<button className="btn btn-mini" onClick={() => setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten</button>
|
||
<button className="card-delete" title="Trigger löschen" onClick={() => removeTrigger(t.keyword)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="Keyword (min 3 Zeichen)" value={triggerDraft.keyword} onChange={(e) => setTriggerDraft({ ...triggerDraft, keyword: e.target.value })} />
|
||
<input type="text" placeholder="Antwort des Bots" value={triggerDraft.reply} onChange={(e) => setTriggerDraft({ ...triggerDraft, reply: e.target.value })} />
|
||
<button className="btn" onClick={saveTriggerDraft}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Tags (/tag)</h2>
|
||
<p className="section-intro">
|
||
Text-Bausteine für wiederkehrende Antworten — jeder Member kann sie mit <code>/tag name</code> abrufen.
|
||
</p>
|
||
{tags.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{tags.map((t) => (
|
||
<div className="key-row" key={t.name}>
|
||
<span className="key-name">{t.name}</span>
|
||
<span className="key-used" title={t.content}>{t.content.slice(0, 50)}</span>
|
||
<button className="btn btn-mini" onClick={() => setTagDraft({ name: t.name, content: t.content })}>Bearbeiten</button>
|
||
<button className="card-delete" title="Tag löschen" onClick={() => removeTag(t.name)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="name (z. B. server-ip)" value={tagDraft.name} onChange={(e) => setTagDraft({ ...tagDraft, name: e.target.value })} />
|
||
<input type="text" placeholder="Inhalt des Tags" value={tagDraft.content} onChange={(e) => setTagDraft({ ...tagDraft, content: e.target.value })} />
|
||
<button className="btn" onClick={saveTagDraft}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabSystem = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Allgemein</h2>
|
||
<div className="field" data-setting="public_url">
|
||
<label>Öffentliche URL <span className="field-hint">Basis für Buttons, RSS, OAuth-Redirect</span></label>
|
||
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.public_url} onChange={(e) => setForm({ ...form, public_url: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="gitea_url">
|
||
<label>Gitea-URL</label>
|
||
<input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Zwei Seiten</h2>
|
||
<p className="section-intro">
|
||
Community-Hub und Bot-Produktseite auf getrennte Adressen legen. Solange
|
||
beide Felder leer sind, läuft alles unverändert auf der öffentlichen URL.
|
||
Sind sie gesetzt, leitet der Server falsch aufgerufene Seiten automatisch
|
||
auf die richtige Adresse um.
|
||
</p>
|
||
<div className="field" data-setting="hub_url">
|
||
<label>Community-Hub <span className="field-hint">Devlogs, Roadmap, Server, Profile — Ziel für Devlog-Buttons, RSS und Link-Vorschauen</span></label>
|
||
<input type="text" placeholder="https://hub.d4rkst3r.de" value={form.hub_url ?? ''} onChange={(e) => setForm({ ...form, hub_url: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="bot_url">
|
||
<label>Bot-Produktseite <span className="field-hint">Funktionen, Befehle und dieses Dashboard</span></label>
|
||
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.bot_url ?? ''} onChange={(e) => setForm({ ...form, bot_url: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="cookie_domain">
|
||
<label>Login-Domain <span className="field-hint">z. B. <code>.d4rkst3r.de</code> — dann gilt die Anmeldung auf beiden Seiten; leer = getrennte Logins</span></label>
|
||
<input type="text" placeholder=".d4rkst3r.de" value={form.cookie_domain ?? ''} onChange={(e) => setForm({ ...form, cookie_domain: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="bug_report_repo">
|
||
<label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</span></label>
|
||
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.bug_report_repo} onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="roadmap_repo">
|
||
<label>Roadmap-Repo <span className="field-hint">Milestones → öffentliche /roadmap</span></label>
|
||
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.roadmap_repo} onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Dienste (Portal)</h2>
|
||
<p className="section-intro">
|
||
Erscheinen als Kacheln auf der Startseite — z. B. Gitea, Kanban oder die Cloud.
|
||
</p>
|
||
{services.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{services.map((s) => (
|
||
<div className="key-row" key={s.id}>
|
||
<span className="key-name">{s.icon} {s.name}</span>
|
||
<span className="key-used" title={s.url}>{s.url}</span>
|
||
<button className="btn btn-mini" onClick={() => setServiceDraft({
|
||
id: s.id, name: s.name, url: s.url, icon: s.icon,
|
||
description: s.description ?? '', sort: s.sort ?? 0,
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Dienst entfernen" onClick={() => removeService(s.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
|
||
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
|
||
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
|
||
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
|
||
</div>
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<input type="text" placeholder="Kurzbeschreibung für die Kachel" value={serviceDraft.description} onChange={(e) => setServiceDraft({ ...serviceDraft, description: e.target.value })} />
|
||
<button className="btn btn-save" onClick={saveService}>{serviceDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||
{serviceDraft.id && <button className="btn btn-ghost" onClick={() => setServiceDraft(emptyService)}>Abbrechen</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Impressum & Datenschutz</h2>
|
||
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
|
||
Erscheint auf <code>/impressum</code> und <code>/datenschutz</code>. Für § 5 DDG
|
||
ist eine ladungsfähige Anschrift nötig (kein Postfach) — nur der Owner sieht
|
||
und ändert diese Felder.
|
||
</p>
|
||
<div className="field" data-setting="legal_name">
|
||
<label>Name / Verantwortlicher</label>
|
||
<input type="text" placeholder="Vorname Nachname" value={form.legal_name ?? ''} onChange={(e) => setForm({ ...form, legal_name: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="legal_address">
|
||
<label>Anschrift <span className="field-hint">mehrzeilig: Straße, PLZ Ort, Land</span></label>
|
||
<textarea rows={3} placeholder={'Musterstraße 1\n12345 Musterstadt\nDeutschland'} value={form.legal_address ?? ''} onChange={(e) => setForm({ ...form, legal_address: e.target.value })} />
|
||
</div>
|
||
<div className="field" data-setting="legal_email">
|
||
<label>Kontakt-E-Mail <span className="field-hint">Pflichtangabe — muss erreichbar sein</span></label>
|
||
<input type="email" placeholder="kontakt@d4rkst3r.de" value={form.legal_email ?? ''} onChange={(e) => setForm({ ...form, legal_email: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Weitere Angaben <span className="field-hint">optional: USt-ID, Telefon, Handelsregister …</span></label>
|
||
<textarea rows={2} placeholder="USt-IdNr.: DE123456789" value={form.legal_extra ?? ''} onChange={(e) => setForm({ ...form, legal_extra: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Web-Login (Member)</h2>
|
||
<div className="field" data-setting="member_gate_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.member_gate_enabled} onChange={(e) => setForm({ ...form, member_gate_enabled: e.target.checked })} />
|
||
<span>Login nur für Discord-Member</span>
|
||
<span className="field-hint">Nicht-Member werden nach dem Discord-Login abgewiesen (braucht gesetzte Guild-ID)</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="discord_invite_url">
|
||
<label>Discord-Invite-Link <span className="field-hint">wird Abgewiesenen als „Zum Discord"-Button angezeigt</span></label>
|
||
<input type="text" placeholder="https://discord.gg/…" value={form.discord_invite_url} onChange={(e) => setForm({ ...form, discord_invite_url: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||
<div className="field" data-setting="events_announce_channel_id">
|
||
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.events_announce_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="social_announce_channel_id">
|
||
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.social_announce_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div className="field" data-setting="youtube_channel_id">
|
||
<div className="field-row">
|
||
<input type="text" placeholder="YouTube-Kanal-ID (UC…)" value={form.youtube_channel_id} onChange={(e) => setForm({ ...form, youtube_channel_id: e.target.value })} />
|
||
<input type="text" placeholder="Twitch-Login (z. B. d4rkst3r)" value={form.twitch_channel} onChange={(e) => setForm({ ...form, twitch_channel: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Twitch-App-Credentials <span className="field-hint">{form.twitch_creds_set ? '✓ gesetzt' : 'dev.twitch.tv → App registrieren'} — write-only</span></label>
|
||
<div className="field-row">
|
||
<input type="password" placeholder="Client-ID" value={twitchCreds.id} onChange={(e) => setTwitchCreds({ ...twitchCreds, id: e.target.value })} />
|
||
<input type="password" placeholder="Client-Secret" value={twitchCreds.secret} onChange={(e) => setTwitchCreds({ ...twitchCreds, secret: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="tempvoice_channel_id">
|
||
<label>Temp-Voice-Hub <span className="field-hint">„Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.tempvoice_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, tempvoice_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Backups & Watchdog</h2>
|
||
<div className="field" data-setting="backup_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.backup_enabled} onChange={(e) => setForm({ ...form, backup_enabled: e.target.checked })} />
|
||
<span>Nächtliches DB-Backup (03:00)</span>
|
||
<span className="field-hint">gzip, 14 Tage Rotation</span>
|
||
</label>
|
||
</div>
|
||
<div className="field" data-setting="backup_channel_id">
|
||
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
|
||
<div className="field-row">
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.backup_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, backup_channel_id: v })}
|
||
empty="— nur lokal —"
|
||
/>
|
||
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="repo_backup_enabled">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.repo_backup_enabled} onChange={(e) => setForm({ ...form, repo_backup_enabled: e.target.checked })} />
|
||
<span>Repo-Backups (04:00)</span>
|
||
<span className="field-hint">alle Gitea-Repos als git-Bundle, 14 Tage Rotation — braucht den Gitea-Token</span>
|
||
</label>
|
||
<div className="field-row" style={{ marginTop: '.5rem' }}>
|
||
<button className="btn" onClick={() => sendTest('repobackup')}>Repos jetzt sichern</button>
|
||
</div>
|
||
</div>
|
||
<div className="field" data-setting="watchdog_urls">
|
||
<label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — leer = aus</span></label>
|
||
<input type="text" placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health" value={form.watchdog_urls} onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
{watchdog?.adressen?.length > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Erreichbarkeit</h2>
|
||
<p className="section-intro">
|
||
Ein Strich je Stunde über die letzten 7 Tage — grün erreichbar, rot
|
||
Ausfall, grau keine Messung. Geprüft wird alle {watchdog.intervall} Minuten.
|
||
{!watchdog.aktiv && ' Der Wächter ist gerade ausgeschaltet, es kommen keine neuen Messpunkte dazu.'}
|
||
</p>
|
||
{watchdog.adressen.map((a) => (
|
||
<div className="wd-card card" key={a.url}>
|
||
<div className="wd-head">
|
||
<span className={`wd-dot ${a.online === null ? 'wartet' : a.online ? 'an' : 'aus'}`} />
|
||
<span className="st-name">{a.name}</span>
|
||
{a.oeffentlich && <span className="tm-chip alle">öffentlich</span>}
|
||
<span className="wd-meta">
|
||
{a.online === null && 'noch nicht geprüft'}
|
||
{a.online === true && `${a.status ?? 'OK'}${a.ms != null ? ` · ${a.ms} ms` : ''}`}
|
||
{a.online === false && `seit ${vorWieLange(new Date(a.seit).toISOString())} weg`}
|
||
</span>
|
||
<button
|
||
className="btn btn-mini"
|
||
onClick={() => setDienstDraft({
|
||
id: a.id, name: a.name, url: a.url,
|
||
gruppe: a.gruppe, oeffentlich: a.oeffentlich,
|
||
})}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button className="card-delete" title="Nicht mehr überwachen" onClick={() => dienstLoeschen(a.id, a.name)}>✕</button>
|
||
</div>
|
||
<a className="wd-url" href={a.url} target="_blank" rel="noreferrer">{a.url}</a>
|
||
<UptimeTrack buckets={a.uptime} />
|
||
</div>
|
||
))}
|
||
|
||
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>
|
||
{dienstDraft.id ? 'Dienst bearbeiten' : 'Dienst hinzufügen'}
|
||
</h3>
|
||
<div className="field">
|
||
<label>Name <span className="field-hint">steht so auf der öffentlichen Statusseite</span></label>
|
||
<input type="text" maxLength={60} placeholder="z. B. Gitea" value={dienstDraft.name}
|
||
onChange={(e) => setDienstDraft({ ...dienstDraft, name: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Adresse <span className="field-hint">wird geprüft; erscheint nie öffentlich</span></label>
|
||
<input type="text" placeholder="https://git.d4rkst3r.de" value={dienstDraft.url}
|
||
onChange={(e) => setDienstDraft({ ...dienstDraft, url: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={dienstDraft.oeffentlich}
|
||
onChange={(e) => setDienstDraft({ ...dienstDraft, oeffentlich: e.target.checked })} />
|
||
Auf der öffentlichen Statusseite zeigen
|
||
</label>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={dienstSpeichern}>
|
||
{dienstDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
|
||
</button>
|
||
{dienstDraft.id && (
|
||
<button className="btn btn-ghost" onClick={() => setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false })}>
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{watchdog?.stoerungen?.length > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Störungen</h2>
|
||
<div className="st-incidents">
|
||
{watchdog.stoerungen.slice(0, 20).map((v) => (
|
||
<div className={`st-incident${v.beendet ? '' : ' offen'}`} key={v.id}>
|
||
<span className="st-inc-name">
|
||
{watchdog.adressen.find((a) => a.url === v.url)?.name ?? v.url}
|
||
</span>
|
||
<span className="st-inc-time">{vorWieLange(v.begonnen)}</span>
|
||
<span className="st-inc-dur">{v.beendet ? `${v.minuten} min` : 'läuft noch'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const tabApi = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
||
{keys.length > 0 && (
|
||
<div className="key-list">
|
||
{keys.map((k) => (
|
||
<div className="key-row" key={k.id}>
|
||
<span className="key-name">{k.name}</span>
|
||
<span className="key-scopes">{k.scopes}</span>
|
||
<span className="key-used">
|
||
{k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
|
||
</span>
|
||
<button className="card-delete" title="Key widerrufen" onClick={() => revokeKey(k.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{createdKey && (
|
||
<div className="key-created">
|
||
<p>Key <strong>{createdKey.name}</strong> erstellt — <strong>jetzt kopieren</strong>, er wird nie wieder angezeigt:</p>
|
||
<code>{createdKey.key}</code>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Neuer Key <span className="field-hint">Name des Skripts/Dienstes + Scopes</span></label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="z. B. devlog.py, platform, fivem" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} />
|
||
<button className="btn" onClick={createKey}>Erstellen</button>
|
||
</div>
|
||
<div className="scope-row">
|
||
{API_SCOPES.map((s) => (
|
||
<label className="scope-pick" key={s.id} title={s.hint}>
|
||
<input
|
||
type="checkbox"
|
||
checked={newKeyScopes.includes(s.id)}
|
||
onChange={(e) =>
|
||
setNewKeyScopes(
|
||
e.target.checked
|
||
? [...newKeyScopes, s.id]
|
||
: newKeyScopes.filter((x) => x !== s.id)
|
||
)
|
||
}
|
||
/>
|
||
{s.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Single Sign-On</h2>
|
||
<p className="section-intro">
|
||
Andere Dienste (Kanban, Platform …) können den Discord-Login mitnutzen: Sie
|
||
leiten auf <code>/sso/authorize</code> weiter und tauschen den Token per
|
||
<code>POST /sso/verify</code> gegen Nutzerdaten samt Discord-Rollen ein.
|
||
</p>
|
||
{ssoApps.length > 0 && (
|
||
<div className="key-list">
|
||
{ssoApps.map((a) => (
|
||
<div className="key-row" key={a.slug}>
|
||
<span className="key-name">{a.name}</span>
|
||
<span className="key-scopes">{a.slug}</span>
|
||
<span className="key-used" title={a.redirect_prefix}>
|
||
{a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'}
|
||
</span>
|
||
<button className="card-delete" title="App entfernen" onClick={() => removeSsoApp(a.slug)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{createdSso && (
|
||
<div className="key-created">
|
||
<p>
|
||
App <strong>{createdSso.name}</strong> registriert — Secret <strong>jetzt kopieren</strong>,
|
||
es wird nie wieder angezeigt:
|
||
</p>
|
||
<code>{createdSso.secret}</code>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Neue App <span className="field-hint">Kürzel, Anzeigename und erlaubte Rücksprung-URL(s)</span></label>
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '9rem' }} placeholder="kanban" value={ssoDraft.slug} onChange={(e) => setSsoDraft({ ...ssoDraft, slug: e.target.value })} />
|
||
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Kanban-Board" value={ssoDraft.name} onChange={(e) => setSsoDraft({ ...ssoDraft, name: e.target.value })} />
|
||
<input type="text" placeholder="https://kanban.d4rkst3r.de/" value={ssoDraft.redirect_prefix} onChange={(e) => setSsoDraft({ ...ssoDraft, redirect_prefix: e.target.value })} />
|
||
<button className="btn" onClick={createSsoApp}>Registrieren</button>
|
||
</div>
|
||
<p className="field-hint" style={{ marginTop: '.4rem' }}>
|
||
Mehrere URLs kommagetrennt. Der Bot leitet nur auf Ziele zurück, die mit
|
||
einem dieser Präfixe beginnen.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabServer = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Game-Server</h2>
|
||
<p className="section-intro">
|
||
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
|
||
Spieler-Balken, Map, Adresse) — aktualisiert alle 2 Minuten, DiscordGSM-Style.
|
||
</p>
|
||
<div className="field" data-setting="status_channel_id">
|
||
<div className="field-row">
|
||
<div style={{ flex: 1 }}>
|
||
<label>Status-Kanal</label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.status_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, status_channel_id: v })}
|
||
empty="— deaktiviert —"
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label>Alert-Kanal <span className="field-hint">🚨 down / ✅ online</span></label>
|
||
<PickSelect
|
||
items={channelItems}
|
||
value={form.server_alert_channel_id ?? ''}
|
||
onChange={(v) => setForm({ ...form, server_alert_channel_id: v })}
|
||
empty="— keine Alerts —"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
|
||
<button className="btn btn-save" onClick={save}>Kanäle speichern</button>
|
||
</div>
|
||
|
||
{servers.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{servers.map((s) => (
|
||
<div className="key-row" key={s.id}>
|
||
<span className="key-name">{s.name}</span>
|
||
<span className="key-scopes">{s.type}</span>
|
||
<span className="key-used" title={s.query_url}>{s.query_url}</span>
|
||
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
|
||
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
|
||
image_url: s.image_url ?? '',
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
|
||
<GameSelect
|
||
presets={GAME_PRESETS}
|
||
value={serverDraft.type}
|
||
onChange={(type) => setServerDraft({ ...serverDraft, type })}
|
||
/>
|
||
{serverNeedsUrl ? (
|
||
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
|
||
) : (
|
||
<>
|
||
<input type="text" placeholder="Host / IP" value={serverDraft.host} onChange={(e) => setServerDraft({ ...serverDraft, host: e.target.value })} />
|
||
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder="Port" value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
|
||
</>
|
||
)}
|
||
</div>
|
||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||
Neben FiveM/HTTP funktioniert jede <a href="https://github.com/gamedig/node-gamedig#games-list" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Spiel-ID</a> (300+ Spiele) — Host + Query-Port eintragen.
|
||
</p>
|
||
</div>
|
||
<div className="field">
|
||
<label>Connect-Link + Anzeige-Adresse <span className="field-hint">Link ist im Embed klickbar (auch steam:// / fivem://); Adresse als Copy-Paste-Block</span></label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
|
||
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
|
||
</div>
|
||
<label style={{ marginTop: '.6rem' }}>Logo-URL <span className="field-hint">https-Bild (z. B. vom CDN) — erscheint auf der /server-Seite und als Embed-Thumbnail</span></label>
|
||
<input type="text" placeholder="https://cdn.d4rkst3r.de/logos/eco.png" value={serverDraft.image_url} onChange={(e) => setServerDraft({ ...serverDraft, image_url: e.target.value })} />
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
// Filter über dem Modul-Raster — bei 35 Karten ist eine lange Liste
|
||
// untereinander mühsam. „Noch einzurichten" ist bewusst dabei: das ist die
|
||
// Frage, mit der man am häufigsten herkommt.
|
||
const shownModules = modules.filter((m) => {
|
||
if (modFilter === 'alle') return true;
|
||
if (modFilter === 'todo') return m.enabled && m.missing.length > 0;
|
||
if (modFilter === 'aus') return !m.enabled;
|
||
return m.group === modFilter;
|
||
});
|
||
const modFilters = [
|
||
{ id: 'alle', label: 'Alle', n: modules.length },
|
||
...moduleGroups.map((g) => ({
|
||
id: g.id, label: g.label, n: modules.filter((m) => m.group === g.id).length,
|
||
})),
|
||
{ id: 'todo', label: 'Noch einzurichten', n: modules.filter((m) => m.enabled && m.missing.length > 0).length },
|
||
{ id: 'aus', label: 'Ausgeschaltet', n: modules.filter((m) => !m.enabled).length },
|
||
];
|
||
const groupLabel = (id) => moduleGroups.find((g) => g.id === id)?.label ?? '';
|
||
|
||
const tabModule = openModule ? (
|
||
<ModulePage
|
||
id={openModule}
|
||
onBack={() => setOpenModule(null)}
|
||
onToggle={toggleModule}
|
||
form={form}
|
||
setForm={setForm}
|
||
PickSelect={PickSelect}
|
||
channelItems={channelItems}
|
||
roleItems={roleItems}
|
||
welcomePreviewUrl={welcomePreviewUrl}
|
||
flash={flash}
|
||
goTab={setTab}
|
||
reloadSettings={reloadSettings}
|
||
favorites={favorites}
|
||
onToggleFavorite={toggleFavorite}
|
||
/>
|
||
) : (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Module</h2>
|
||
<p className="section-intro">
|
||
Jede Funktion des Bots lässt sich hier ein- und ausschalten. Ausgeschaltete
|
||
Module reagieren auf nichts mehr — keine Posts, keine Befehle, keine
|
||
Hintergrund-Prüfungen. Was noch eingerichtet werden muss, steht direkt an
|
||
der Karte.
|
||
</p>
|
||
|
||
<div className="mod-filters">
|
||
{modFilters.map((f) => (
|
||
<button
|
||
key={f.id}
|
||
className={`mod-filter${modFilter === f.id ? ' active' : ''}`}
|
||
disabled={f.n === 0 && modFilter !== f.id}
|
||
onClick={() => setModFilter(f.id)}
|
||
>
|
||
{f.label} <span className="mod-filter-n">{f.n}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{shownModules.length === 0 ? (
|
||
<p className="notice">Hier ist gerade nichts.</p>
|
||
) : (
|
||
<div className="mod-grid">
|
||
{shownModules.map((m) => {
|
||
const Icon = moduleIcon(m.id);
|
||
return (
|
||
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
|
||
<div className="mod-head">
|
||
<button
|
||
className="mod-open"
|
||
title={`${m.name} einrichten`}
|
||
onClick={() => setOpenModule(m.id)}
|
||
>
|
||
<span className="mod-icon"><Icon size={20} /></span>
|
||
<span className="mod-titles">
|
||
<span className="mod-name">{m.name}</span>
|
||
<span className="mod-group">{groupLabel(m.group)}</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
className={`mod-fav${favorites.includes(m.id) ? ' on' : ''}`}
|
||
title={favorites.includes(m.id)
|
||
? 'Aus der Seitenleiste nehmen'
|
||
: 'In die Seitenleiste heften'}
|
||
onClick={() => toggleFavorite(m.id)}
|
||
>
|
||
<IconStar size={15} />
|
||
</button>
|
||
<button
|
||
className={`mod-switch${m.enabled ? ' on' : ''}`}
|
||
title={m.enabled ? 'Ausschalten' : 'Einschalten'}
|
||
onClick={() => toggleModule(m.id, !m.enabled)}
|
||
>
|
||
<span className="mod-knob" />
|
||
</button>
|
||
</div>
|
||
<p className="mod-desc">{m.desc}</p>
|
||
<div className="mod-foot">
|
||
{m.enabled && m.missing.length > 0 && (
|
||
<span className="mod-missing with-icon">
|
||
<IconWarning size={13} /> Fehlt: {m.missing.map((x) => x.label).join(', ')}
|
||
</span>
|
||
)}
|
||
{m.enabled && m.missing.length === 0 && (
|
||
<span className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</span>
|
||
)}
|
||
{!m.enabled && <span className="mod-off">Ausgeschaltet</span>}
|
||
<button className="mod-link" onClick={() => setOpenModule(m.id)}>
|
||
Einrichten →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const tabTexte = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Texte</h2>
|
||
<p className="section-intro">
|
||
Alle Texte am Stück — wer nur die einer Funktion sucht, findet sie
|
||
auch auf deren Modul-Seite. Zeile anklicken zum Bearbeiten —
|
||
die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein,
|
||
ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
|
||
bedeutet: wieder der Standardtext.
|
||
</p>
|
||
|
||
{templateGroups.map((group) => {
|
||
const items = templates.filter((t) => t.group === group.id);
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
|
||
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
|
||
{group.label}
|
||
</h3>
|
||
<div className="tpl-list">
|
||
{items.map((t) => {
|
||
const draft = tplDraft[t.id];
|
||
const value = draft ?? t.text;
|
||
const dirty = draft != null && draft !== t.text;
|
||
const off = t.module && modules.some((m) => m.id === t.module && !m.enabled);
|
||
const open = openTpl === t.id;
|
||
return (
|
||
<div
|
||
className={`tpl-row${open ? ' open' : ''}${dirty ? ' dirty' : ''}`}
|
||
key={t.id}
|
||
>
|
||
<button
|
||
className="tpl-head"
|
||
aria-expanded={open}
|
||
onClick={() => setOpenTpl(open ? null : t.id)}
|
||
>
|
||
<span className="tpl-head-main">
|
||
<span className="tpl-name">{t.name}</span>
|
||
{/* Zusammengeklappt reicht die erste Zeile als Erkennungszeichen */}
|
||
<span className="tpl-peek">{value.split('\n')[0]}</span>
|
||
</span>
|
||
{dirty && <span className="tpl-badge">ungespeichert</span>}
|
||
{!dirty && t.customized && <span className="tpl-badge">angepasst</span>}
|
||
{off && <span className="tpl-badge muted">Modul aus</span>}
|
||
<IconChevronDown className={`tpl-caret${open ? ' up' : ''}`} size={15} />
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="tpl-body">
|
||
<textarea
|
||
id={`tpl-${t.id}`}
|
||
className="tpl-input"
|
||
rows={t.long ? 5 : 2}
|
||
value={value}
|
||
spellCheck={false}
|
||
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
|
||
/>
|
||
|
||
{t.vars.length > 0 && (
|
||
<div className="tpl-vars">
|
||
<span className="tpl-vars-label">Bausteine:</span>
|
||
{t.vars.map((v) => (
|
||
<button
|
||
key={v.key}
|
||
className="tpl-var"
|
||
title={`${v.desc} — einfügen`}
|
||
onClick={() => insertPlaceholder(t, v.key)}
|
||
>
|
||
{`{${v.key}}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="tpl-actions">
|
||
<button
|
||
className="btn"
|
||
disabled={!dirty}
|
||
onClick={() => saveTemplate(t.id, value)}
|
||
>
|
||
Speichern
|
||
</button>
|
||
{(t.customized || dirty) && (
|
||
<button
|
||
className="btn btn-ghost with-icon"
|
||
onClick={() => saveTemplate(t.id, '')}
|
||
title="Standardtext wiederherstellen"
|
||
>
|
||
<IconRefresh size={13} /> Standard
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
|
||
const tuneDirty = tuningValues.some(
|
||
(t) => tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value)
|
||
);
|
||
|
||
const tabWerte = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Werte</h2>
|
||
<p className="section-intro">
|
||
Alle Werte am Stück; jeder steht zusätzlich auf der Seite seiner
|
||
Funktion. Zahlen, die das Verhalten steuern — wie viel XP eine Nachricht bringt,
|
||
wie oft geprüft wird, wann gepostet wird. Geänderte Intervalle greifen ab
|
||
dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte
|
||
Bereich; angepasste Werte lassen sich einzeln zurücksetzen.
|
||
</p>
|
||
|
||
{tuningGroups.map((group) => {
|
||
const items = tuningValues.filter((t) => t.group === group.id);
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div key={group.id} style={{ marginBottom: '1.6rem' }}>
|
||
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
|
||
{group.label}
|
||
</h3>
|
||
<div className="tune-list">
|
||
{items.map((t) => {
|
||
const value = tuneDraft[t.id] ?? t.value;
|
||
const dirty = tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value);
|
||
return (
|
||
<div className={`tune-row${dirty ? ' dirty' : ''}`} key={t.id}>
|
||
<div className="tune-label">
|
||
<span className="tune-name">
|
||
{t.label}
|
||
{t.customized && <span className="tpl-badge">angepasst</span>}
|
||
</span>
|
||
{t.hint && <span className="tune-hint">{t.hint}</span>}
|
||
</div>
|
||
<div className="tune-input">
|
||
{t.choices ? (
|
||
<select
|
||
value={value}
|
||
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
|
||
>
|
||
{t.choices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
) : (
|
||
<input
|
||
type="number"
|
||
min={t.min} max={t.max} step={1}
|
||
value={value}
|
||
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
|
||
/>
|
||
)}
|
||
<span className="tune-unit">{t.unit}</span>
|
||
<span className="tune-range">
|
||
{t.choices ? t.choices.join(' · ') : `${t.min}–${t.max}`}
|
||
{' · Standard '}{t.default}
|
||
</span>
|
||
{t.customized && (
|
||
<button
|
||
className="btn-mini"
|
||
title="Standard wiederherstellen"
|
||
onClick={() => resetTuning(t.id)}
|
||
>
|
||
Standard
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" disabled={!tuneDirty} onClick={saveTuning}>
|
||
Werte speichern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const tabSeiten = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Eigene Seiten</h2>
|
||
<p className="section-intro">
|
||
Inhalte pflegen, ohne dass jemand Code anfassen muss — Regeln, Über uns,
|
||
Mitmachen, FAQ. Geschrieben wird in Markdown (wie im Devlog), rechts siehst
|
||
du sofort, wie es aussieht.
|
||
</p>
|
||
|
||
{pages.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{pages.map((p) => (
|
||
<div className="key-row" key={p.slug}>
|
||
<span className="key-name">{p.title}</span>
|
||
<span className="key-scopes">/{p.slug}</span>
|
||
<span className="key-used">
|
||
{p.published ? (p.in_menu ? 'sichtbar · im Menü' : 'sichtbar · nur per Link') : 'Entwurf'}
|
||
</span>
|
||
<button className="btn btn-mini" onClick={() => setPageDraft({
|
||
slug: p.slug, title: p.title, content: p.content,
|
||
published: p.published, in_menu: p.in_menu, sort: p.sort ?? 0,
|
||
isNew: false,
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Seite löschen" onClick={() => removePage(p.slug)}>
|
||
<IconX size={15} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{pageDraft.isNew ? 'Neue Seite' : `Bearbeiten: /${pageDraft.slug}`}</label>
|
||
<div className="field-row">
|
||
<input
|
||
type="text" style={{ flex: 'none', width: '14rem' }} placeholder="Titel, z. B. Regeln"
|
||
value={pageDraft.title}
|
||
onChange={(e) => setPageDraft({
|
||
...pageDraft,
|
||
title: e.target.value,
|
||
// Kürzel nur bei neuen Seiten automatisch mitziehen
|
||
slug: pageDraft.isNew ? slugify(e.target.value) : pageDraft.slug,
|
||
})}
|
||
/>
|
||
<input
|
||
type="text" style={{ flex: 'none', width: '11rem' }} placeholder="kuerzel"
|
||
value={pageDraft.slug}
|
||
onChange={(e) => setPageDraft({ ...pageDraft, slug: slugify(e.target.value) })}
|
||
/>
|
||
<span className="field-hint" style={{ alignSelf: 'center' }}>
|
||
Adresse: /{pageDraft.slug || '…'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="composer-grid">
|
||
<div className="field">
|
||
<label>Inhalt <span className="field-hint">Markdown: **fett**, *kursiv*, ## Überschrift, - Liste, [Link](url)</span></label>
|
||
<textarea
|
||
rows={18} placeholder={'## Willkommen\n\nHier stehen unsere Regeln.\n\n- Sei nett\n- Kein Spam'}
|
||
value={pageDraft.content}
|
||
onChange={(e) => setPageDraft({ ...pageDraft, content: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Vorschau</label>
|
||
<div className="card page-preview">
|
||
<div className="card-body">
|
||
{pageDraft.content.trim()
|
||
? <Markdown text={pageDraft.content} />
|
||
: <p className="field-hint">Noch kein Inhalt.</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox" checked={pageDraft.published}
|
||
onChange={(e) => setPageDraft({ ...pageDraft, published: e.target.checked })}
|
||
/>
|
||
<span>Veröffentlicht</span>
|
||
<span className="field-hint">aus = Entwurf, nur für dich sichtbar</span>
|
||
</label>
|
||
<label className="toggle" style={{ marginTop: '.5rem' }}>
|
||
<input
|
||
type="checkbox" checked={pageDraft.in_menu}
|
||
onChange={(e) => setPageDraft({ ...pageDraft, in_menu: e.target.checked })}
|
||
/>
|
||
<span>Im Community-Menü zeigen</span>
|
||
<span className="field-hint">aus = nur über den Direktlink erreichbar</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="field-row">
|
||
<input
|
||
type="number" style={{ flex: 'none', width: '7rem' }} placeholder="Position"
|
||
value={pageDraft.sort}
|
||
onChange={(e) => setPageDraft({ ...pageDraft, sort: e.target.value })}
|
||
/>
|
||
<button className="btn btn-save" onClick={savePageDraft}>
|
||
{pageDraft.isNew ? 'Anlegen' : 'Speichern'}
|
||
</button>
|
||
{!pageDraft.isNew && (
|
||
<button className="btn btn-ghost" onClick={() => setPageDraft(emptyPage)}>Neue Seite</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
async function dienstSpeichern() {
|
||
const body = { ...dienstDraft };
|
||
try {
|
||
if (body.id) await apiPut(`/api/monitored/${body.id}`, body);
|
||
else await apiPost('/api/monitored', body);
|
||
setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||
setFeedback('Gespeichert.');
|
||
} catch (e) {
|
||
setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.');
|
||
}
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
|
||
async function dienstLoeschen(id, name) {
|
||
if (!window.confirm(`\u201e${name}" nicht mehr überwachen?`)) return;
|
||
await apiDelete(`/api/monitored/${id}`).catch(() => {});
|
||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||
}
|
||
|
||
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
|
||
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
|
||
|
||
const tabTeam = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Team</h2>
|
||
<p className="section-intro">
|
||
Team-Mitglieder loggen sich ganz normal mit Discord ein und sehen nur ihre
|
||
Bereiche. Brand, System, API-Keys und diese Verwaltung bleiben immer dir
|
||
vorbehalten.
|
||
</p>
|
||
|
||
<div className="tm-list">
|
||
{owner && (
|
||
<div className="tm-row owner">
|
||
<Avatar src={owner.avatar} name={owner.name ?? me.user?.username} />
|
||
<div className="tm-main">
|
||
<span className="tm-name">{owner.name ?? me.user?.username ?? 'Du'}</span>
|
||
<span className="tm-scopes"><span className="tm-chip alle">Alle Bereiche</span></span>
|
||
</div>
|
||
<span className="tm-role">Owner</span>
|
||
</div>
|
||
)}
|
||
{team.map((t) => (
|
||
<div className={`tm-row${t.user_id === teamDraft.user_id ? ' aktiv' : ''}`} key={t.user_id}>
|
||
<Avatar src={t.avatar} name={t.name ?? t.username} />
|
||
<div className="tm-main">
|
||
<span className="tm-name">{t.name ?? t.username ?? t.user_id}</span>
|
||
<span className="tm-scopes">
|
||
{t.scopes.split(',').filter(Boolean).map((sc) => (
|
||
<span className="tm-chip" key={sc}>{TEAM_SCOPE_LABELS[sc] ?? sc}</span>
|
||
))}
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="btn btn-mini"
|
||
onClick={() => setTeamDraft({
|
||
user_id: t.user_id,
|
||
scopes: t.scopes.split(',').filter(Boolean),
|
||
})}
|
||
>
|
||
Rechte
|
||
</button>
|
||
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||
</div>
|
||
))}
|
||
{team.length === 0 && (
|
||
<div className="tm-row leer">Außer dir hat noch niemand Zugriff.</div>
|
||
)}
|
||
</div>
|
||
|
||
<h3 className="tm-form-title">
|
||
{teamBearbeitet ? 'Rechte ändern' : 'Jemanden hinzufügen'}
|
||
</h3>
|
||
<div className="field">
|
||
<label>
|
||
Discord-User-ID
|
||
<span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span>
|
||
</label>
|
||
<input
|
||
type="text" placeholder="z. B. 123456789012345678"
|
||
value={teamDraft.user_id}
|
||
onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Bereiche <span className="field-hint">mindestens einer</span></label>
|
||
<div className="tm-scopes-pick">
|
||
{Object.entries(TEAM_SCOPE_LABELS).map(([sid, label]) => {
|
||
const an = teamDraft.scopes.includes(sid);
|
||
return (
|
||
<button
|
||
type="button" key={sid}
|
||
className={`tm-scope${an ? ' on' : ''}`}
|
||
onClick={() => setTeamDraft({
|
||
...teamDraft,
|
||
scopes: an
|
||
? teamDraft.scopes.filter((x) => x !== sid)
|
||
: [...teamDraft.scopes, sid],
|
||
})}
|
||
>
|
||
<span className="tm-scope-mark">{an ? '✓' : ''}</span>
|
||
<span className="tm-scope-text">
|
||
<span className="tm-scope-id">{sid}</span>
|
||
<span className="tm-scope-desc">{label}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={saveTeam}>
|
||
{teamBearbeitet ? 'Rechte speichern' : 'Hinzufügen'}
|
||
</button>
|
||
{(teamDraft.user_id || teamDraft.scopes.length > 0) && (
|
||
<button className="btn btn-ghost" onClick={() => setTeamDraft({ user_id: '', scopes: [] })}>
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{audit.length > 0 && (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Protokoll</h2>
|
||
<p className="section-intro">
|
||
Wer hat was im Webinterface geändert.
|
||
</p>
|
||
<div className="tm-list">
|
||
{auditSichtbar.map((a) => (
|
||
<div className="tm-row audit" key={a.id}>
|
||
<Avatar src={a.avatar} name={a.username} klein />
|
||
<span className="tm-audit-name">{a.username}</span>
|
||
<span className="tm-audit-action">{a.action}</span>
|
||
<span className="tm-audit-detail" title={a.detail}>{a.detail}</span>
|
||
<span className="tm-audit-time" title={a.ts.replace('T', ' ')}>{vorWieLange(a.ts)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{audit.length > 12 && (
|
||
<div className="settings-actions" style={{ marginTop: '.8rem' }}>
|
||
<button className="btn btn-ghost" onClick={() => setAuditAlle(!auditAlle)}>
|
||
{auditAlle ? 'Weniger zeigen' : `Alle ${audit.length} zeigen`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const content = {
|
||
status: tabStatus,
|
||
module: tabModule,
|
||
texte: tabTexte,
|
||
werte: tabWerte,
|
||
brand: tabBrand,
|
||
feeds: tabFeeds,
|
||
community: tabCommunity,
|
||
rollen: tabRollen,
|
||
support: tabSupport,
|
||
bewerbungen: tabBewerbungen,
|
||
composer: tabComposer,
|
||
seiten: tabSeiten,
|
||
server: tabServer,
|
||
system: tabSystem,
|
||
api: tabApi,
|
||
team: tabTeam,
|
||
}[tab];
|
||
|
||
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
||
|
||
return (
|
||
<>
|
||
{header}
|
||
<section className="content content-wide">
|
||
<div className="settings-layout">
|
||
<nav className="settings-nav">
|
||
<div className="settings-search">
|
||
<IconSearch size={15} />
|
||
<input
|
||
type="text"
|
||
value={tabSearch}
|
||
placeholder="Suchen …"
|
||
aria-label="Bereich suchen"
|
||
onChange={(e) => setTabSearch(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
// Einstellungen zuerst: wer „schwellwert" tippt,
|
||
// will das Feld, nicht den Bereich
|
||
if (e.key === 'Enter') {
|
||
if (foundSettings[0]) jumpToSetting(foundSettings[0]);
|
||
else if (foundTabs[0]) { setTab(foundTabs[0].id); setTabSearch(''); }
|
||
}
|
||
if (e.key === 'Escape') setTabSearch('');
|
||
}}
|
||
/>
|
||
{tabSearch && (
|
||
<button className="settings-search-clear" aria-label="Suche leeren" onClick={() => setTabSearch('')}>
|
||
<IconX size={13} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{foundSettings.length > 0 && (
|
||
<div className="settings-nav-group">
|
||
<span className="settings-nav-label">Einstellungen</span>
|
||
{foundSettings.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
className="settings-hit"
|
||
onClick={() => jumpToSetting(s)}
|
||
>
|
||
<span className="settings-hit-label">{s.label}</span>
|
||
<span className="settings-hit-tab">
|
||
{TABS.find((t) => t.id === s.tab)?.label ?? s.tab}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{foundTabs.length === 0 && foundSettings.length === 0 && (
|
||
<p className="settings-nav-empty">Nichts gefunden.</p>
|
||
)}
|
||
|
||
{/* Angeheftete Funktionen — direkt erreichbar, ohne Umweg
|
||
übers Raster. Bei aktiver Suche ausgeblendet, dort
|
||
zählen Treffer. */}
|
||
{!query && canTab('module') && favorites.length > 0 && (
|
||
<div className="settings-nav-group">
|
||
<span className="settings-nav-label">Meine Funktionen</span>
|
||
{favorites.map((id) => {
|
||
const m = modules.find((x) => x.id === id);
|
||
if (!m) return null;
|
||
const Icon = moduleIcon(id);
|
||
return (
|
||
<button
|
||
key={id}
|
||
className={`settings-nav-item ${openModule === id ? 'active' : ''}`}
|
||
onClick={() => { setTabState('module'); setOpenModule(id); }}
|
||
>
|
||
<span className="settings-nav-icon"><Icon /></span>
|
||
{m.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{TAB_SECTIONS.map((section) => {
|
||
const items = foundTabs.filter((t) => t.section === section.id);
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div className="settings-nav-group" key={section.id}>
|
||
<span className="settings-nav-label">{section.label}</span>
|
||
{items.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
className={`settings-nav-item ${tab === t.id && !(t.id === 'module' && openModule) ? 'active' : ''}`}
|
||
onClick={() => setTab(t.id)}
|
||
>
|
||
<span className="settings-nav-icon"><t.Icon /></span>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
|
||
{content}
|
||
{/* Platz, damit die Speichern-Leiste nichts verdeckt */}
|
||
{dirtyCount > 0 && <div style={{ height: '4.5rem' }} />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Erscheint erst, wenn wirklich etwas offen ist — vorher stand der
|
||
Knopf am Seitenende und war bei langen Bereichen nicht zu sehen. */}
|
||
{dirtyCount > 0 && (
|
||
<div className="save-bar" role="status">
|
||
<span className="save-bar-text">
|
||
{dirtyCount === 1 ? '1 Änderung offen' : `${dirtyCount} Änderungen offen`}
|
||
</span>
|
||
<button className="btn btn-ghost" onClick={discardChanges}>Verwerfen</button>
|
||
<button className="btn btn-save" onClick={saveAll}>Speichern</button>
|
||
</div>
|
||
)}
|
||
|
||
{feedback && <div className="feedback-float">{feedback}</div>}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|