4129 lines
216 KiB
React
4129 lines
216 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, IconGame, IconTrash,
|
||
} from '../icons.jsx';
|
||
import { moduleIcon } from '../module-icons.jsx';
|
||
import ModulePage from '../components/ModulePage.jsx';
|
||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||
import Dialog from '../components/Dialog.jsx';
|
||
import EmojiPick from '../components/EmojiPick.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: 'playtester', Icon: IconGame, label: 'Playtester', section: 'community',
|
||
find: 'tester alpha keys schlüssel vergeben pool testen einladen' },
|
||
{ 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' },
|
||
];
|
||
|
||
// Stand eines Wunsches. Reihenfolge = Weg durchs Verfahren, damit die Knöpfe
|
||
// in der Ansicht so nebeneinander liegen, wie man sie durchläuft.
|
||
const WUNSCH_STATUS = [
|
||
['offen', 'Wird geprüft'],
|
||
['geplant', 'Geplant'],
|
||
['in_arbeit', 'In Arbeit'],
|
||
['umgesetzt', 'Umgesetzt'],
|
||
['abgelehnt', 'Nicht geplant'],
|
||
];
|
||
|
||
// 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',
|
||
playtester: 'community',
|
||
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" */
|
||
// Was ohne Suche oben steht. Nur eine Startauswahl — gesucht wird über alle
|
||
// Spiele, die gamedig kennt. Die IDs hier sind echte gamedig-IDs; wer eine
|
||
// ergänzt, prüft sie gegen /api/gamedig-games und nicht gegen sein Gedächtnis.
|
||
const BELIEBTE_SPIELE = [
|
||
'fivem', 'http', 'minecraft', 'mbe', 'rust', 'valheim', 'palworld',
|
||
'farmingsimulator25', 'projectzomboid', 'sdtd', 'ase', 'garrysmod',
|
||
'counterstrike2', 'arma3', 'satisfactory', 'unturned',
|
||
];
|
||
const GAMESEL_MAX = 60;
|
||
|
||
/**
|
||
* Ganze Abfrage-URL eingefügt statt Host getippt? Dann Host, Port und Code
|
||
* daraus ziehen.
|
||
*
|
||
* Bei Farming Simulator ist genau das der Normalfall: der Hoster gibt einem
|
||
* eine fertige URL wie
|
||
* `http://1.2.3.4:8200/feed/dedicated-server-stats.xml?code=…`,
|
||
* und die drei Felder von Hand auseinanderzupflücken ist Arbeit, die niemand
|
||
* machen muss. Der Port steht mit drin — er ist nicht immer 8080.
|
||
*
|
||
* @returns {{host: string, port: string, token?: string} | null}
|
||
*/
|
||
function feedUrlZerlegen(text) {
|
||
const roh = String(text).trim();
|
||
if (!/^https?:\/\//i.test(roh)) return null;
|
||
let u;
|
||
try { u = new URL(roh); } catch { return null; }
|
||
if (!u.hostname) return null;
|
||
const code = u.searchParams.get('code') || u.searchParams.get('token') || '';
|
||
return {
|
||
host: u.hostname,
|
||
port: u.port || '',
|
||
// Ohne Code im Link den vorhandenen stehen lassen, statt ihn zu leeren
|
||
...(code ? { token: code } : {}),
|
||
};
|
||
}
|
||
|
||
/** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon.
|
||
* Die Liste kommt aus gamedig selbst, damit sie mit jedem Update mitwächst;
|
||
* freie IDs bleiben trotzdem erlaubt. */
|
||
function GameSelect({ games, 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();
|
||
// Ohne Eingabe die Startauswahl, sonst alles durchsuchen. 360 Einträge auf
|
||
// einmal wären keine Auswahl mehr, sondern eine Wand.
|
||
const treffer = q
|
||
? games.filter((g) => g.id.includes(q) || g.name.toLowerCase().includes(q))
|
||
: BELIEBTE_SPIELE.map((id) => games.find((g) => g.id === id)).filter(Boolean);
|
||
const gezeigt = treffer.slice(0, GAMESEL_MAX);
|
||
const rest = treffer.length - gezeigt.length;
|
||
|
||
return (
|
||
<div className="gamesel" ref={wrapRef} style={{ flex: 'none', width: '13rem' }}>
|
||
<input
|
||
type="text" placeholder="Spiel suchen …"
|
||
title="Name oder gamedig-ID — es geht auch jede ID, die nicht in der Liste steht"
|
||
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 && gezeigt.length > 0 && (
|
||
<div className="gamesel-list">
|
||
{gezeigt.map((g) => (
|
||
<button
|
||
type="button" key={g.id}
|
||
className={`gamesel-item${g.id === value ? ' active' : ''}`}
|
||
onMouseDown={(e) => { e.preventDefault(); onChange(g.id); setOpen(false); }}
|
||
>
|
||
<span className="gamesel-label">
|
||
{GAME_EMOJI[g.id] ?? (g.eigen ? '🔧' : '🎮')} {g.name}
|
||
{g.token && <span className="gamesel-key" title="braucht einen Zugangs-Code">🔑</span>}
|
||
</span>
|
||
<span className="gamesel-id">{g.id}</span>
|
||
</button>
|
||
))}
|
||
{rest > 0 && <div className="gamesel-rest">… und {rest} weitere — weitertippen</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Nur Deko in der Auswahlliste; die Seite holt sich ihr Icon aus der API.
|
||
const GAME_EMOJI = {
|
||
fivem: '🚗', http: '🌐', minecraft: '⛏️', mbe: '⛏️', rust: '🪓', valheim: '🛡️',
|
||
palworld: '🐑', farmingsimulator19: '🚜', farmingsimulator22: '🚜', farmingsimulator25: '🚜',
|
||
projectzomboid: '🧟', sdtd: '🧟', dayz: '🧟', ase: '🦖', asa: '🦖',
|
||
garrysmod: '🔧', counterstrike2: '🔫', csgo: '🔫', arma3: '🪖', squad: '🪖',
|
||
satisfactory: '🏭', unturned: '🏝️', terrariatshock: '🌳', teamfortress2: '🎩',
|
||
};
|
||
|
||
/**
|
||
* 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
|
||
// 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);
|
||
// Offene Dialoge: Schlüssel nachlegen bzw. jemanden auswählen
|
||
const [keyDialog, setKeyDialog] = useState(false);
|
||
const [keyEmpfaenger, setKeyEmpfaenger] = useState(null);
|
||
const [keyUpload, setKeyUpload] = useState('');
|
||
// Bewerbungs-Formulare
|
||
const emptyForm = {
|
||
id: null, title: '', description: '', review_channel_id: '',
|
||
approve_role_id: '', post_channel_id: '', questions: [''], playtester: false,
|
||
};
|
||
const [appForms, setAppForms] = useState([]);
|
||
const [formDraft, setFormDraft] = useState(emptyForm);
|
||
// Triggers
|
||
const [triggers, setTriggers] = useState([]);
|
||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||
// Game-Server — die Spielliste kommt aus gamedig (siehe /api/gamedig-games)
|
||
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '', token: '' };
|
||
const [servers, setServers] = useState([]);
|
||
const [games, setGames] = 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 [anliegen, setAnliegen] = useState([]);
|
||
const [wuensche, setWuensche] = useState([]);
|
||
const [statusDialog, setStatusDialog] = useState(null);
|
||
const [statusGrund, setStatusGrund] = useState('');
|
||
const [mergeVon, setMergeVon] = useState(null);
|
||
const [bereiche, setBereiche] = useState([]);
|
||
const [bereichDraft, setBereichDraft] = useState({ id: null, name: '', emoji: '' });
|
||
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
||
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/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/gamedig-games').then((d) => setGames(d.games ?? [])).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/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
|
||
apiGet('/api/wishes').then((d) => {
|
||
setWuensche(d.wishes ?? []);
|
||
setBereiche(d.bereiche ?? []);
|
||
}).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);
|
||
const gewaehltesSpiel = games.find((g) => g.id === serverDraft.type) ?? null;
|
||
/** Ins Host-Feld getippt — oder die ganze Abfrage-URL eingefügt */
|
||
function hostEingeben(wert) {
|
||
const teile = feedUrlZerlegen(wert);
|
||
setServerDraft((d) => (teile ? { ...d, ...teile } : { ...d, host: wert }));
|
||
}
|
||
/** Spiel gewählt: Standard-Port vorschlagen, solange keiner drinsteht */
|
||
function spielWaehlen(type) {
|
||
const spiel = games.find((g) => g.id === type);
|
||
setServerDraft((d) => ({
|
||
...d, type,
|
||
port: d.port || (spiel && !spiel.eigen ? String(spiel.port ?? '') : d.port),
|
||
}));
|
||
}
|
||
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;
|
||
}
|
||
// Ohne Code antwortet so ein Server gar nicht — er stünde dauerhaft auf
|
||
// "offline", ohne dass irgendwo der Grund stünde. Lieber hier stoppen.
|
||
if (gewaehltesSpiel?.token === 'pflicht' && !serverDraft.token.trim()) {
|
||
flash(`✗ ${gewaehltesSpiel.name} antwortet nur mit Zugangs-Code`);
|
||
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');
|
||
}
|
||
}
|
||
|
||
/** Einen Schlüssel an eine ausgewählte Person schicken */
|
||
async function keyGeben(userId, name) {
|
||
try {
|
||
const res = await apiPost('/api/alphakeys/assign', { user_id: userId });
|
||
setAlphaKeys(res);
|
||
setKeyEmpfaenger(null);
|
||
flash(`✓ Schlüssel an ${name} verschickt`);
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Vergeben fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
/** Vergebenen Schlüssel zurück in den Vorrat */
|
||
async function keyZurueck(key) {
|
||
if (!window.confirm(`Schlüssel ${key} wieder freigeben? Die Person behält die Nachricht mit dem Schlüssel.`)) return;
|
||
try {
|
||
setAlphaKeys(await apiPost('/api/alphakeys/revoke', { key }));
|
||
} catch {
|
||
flash('✗ Zurückziehen fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
/** Freien Schlüssel löschen — z. B. einen vertippten */
|
||
async function keyLoeschen(key) {
|
||
try {
|
||
setAlphaKeys(await apiDelete(`/api/alphakeys/${encodeURIComponent(key)}`));
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Löschen fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
async function playtesterEntfernen(userId, name) {
|
||
if (!window.confirm(`${name} aus dem Playtester-Programm nehmen? Die Rolle wird mit entfernt.`)) return;
|
||
try {
|
||
const res = await apiDelete(`/api/playtesters/${userId}`);
|
||
setAlphaKeys(await apiGet('/api/alphakeys'));
|
||
flash(res.rolleWeg ? `✓ ${name} entfernt` : `✓ ${name} entfernt — Rolle blieb (Rechte prüfen)`);
|
||
} catch {
|
||
flash('✗ Entfernen 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></>;
|
||
|
||
// Foren stehen mit dabei — sichtbar gekennzeichnet, weil dort nur der
|
||
// Voting-Kanal sinnvoll ist: posten kann der Bot in ein Forum nicht
|
||
const channelItems = data.channels.map((c) => ({
|
||
id: c.id,
|
||
label: `${c.forum ? '📋' : '#'}${c.name}`,
|
||
hint: c.forum ? 'Forum' : 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.ping != null && ` · ${data.status.ping} ms`}
|
||
{data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Die Laufzeit oben zählt nur bis zum letzten Neustart. Der
|
||
Balken zeigt die Woche davor — inklusive der Zeiten, in
|
||
denen gar nichts lief. */}
|
||
{data.status.uptime?.length > 0 ? (
|
||
<div className="dash-uptrack">
|
||
<UptimeTrack buckets={data.status.uptime} />
|
||
</div>
|
||
) : (
|
||
<p className="dash-hint">
|
||
Erreichbarkeit wird ab jetzt aufgezeichnet — der Balken füllt sich
|
||
in den nächsten Stunden.
|
||
</p>
|
||
)}
|
||
|
||
<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';
|
||
// Willkommens-Karte: eigener Block, damit sie im Brand-Bereich stehen
|
||
// kann, wo das uebrige Aussehen gepflegt wird — inhaltlich unveraendert.
|
||
const willkommensKarte = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Willkommen</h2>
|
||
<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>
|
||
|
||
<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 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>
|
||
{willkommensKarte}
|
||
</>
|
||
);
|
||
|
||
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 className="settings-section">
|
||
<h2 className="settings-title">// Twitch & YouTube</h2>
|
||
<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>
|
||
</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">// Wünsche</h2>
|
||
<p className="section-intro">
|
||
Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den beim nächsten Mal
|
||
niemand mehr einreicht. Der Stand steht auf der Roadmap und im Discord-Post;
|
||
beim Sprung auf <strong>umgesetzt</strong> bekommt jeder eine Nachricht, der
|
||
dafür gestimmt hat.
|
||
</p>
|
||
|
||
{wuensche.length === 0 ? (
|
||
<p className="notice">Noch keine Wünsche eingegangen.</p>
|
||
) : (
|
||
<div className="key-list">
|
||
{wuensche.map((w) => (
|
||
<div className="wu-row card" key={w.id}>
|
||
<div className="wu-head">
|
||
<span className="wish-score with-icon">👍 {w.score}</span>
|
||
<span className="wu-idee">{w.idea}</span>
|
||
<span className="key-used">{w.author}</span>
|
||
</div>
|
||
<div className="wu-aktionen">
|
||
{WUNSCH_STATUS.map(([id, label]) => (
|
||
<button
|
||
key={id}
|
||
className={`wish-chip${(w.status ?? 'offen') === id ? ' aktiv' : ''}`}
|
||
onClick={() => wunschStatus(w, id)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
<button
|
||
className="btn btn-mini"
|
||
style={{ marginLeft: 'auto' }}
|
||
onClick={() => setMergeVon(w)}
|
||
title="Als Doppler auf einen anderen Wunsch zeigen lassen"
|
||
>
|
||
Doppler
|
||
</button>
|
||
<button className="card-delete" title="Wunsch löschen" onClick={() => wunschLoeschen(w)}>✕</button>
|
||
</div>
|
||
{w.status_grund && <p className="wu-grund">{w.status_grund}</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{statusDialog && (
|
||
<Dialog
|
||
title={`${statusDialog.label} — kurz begründen`}
|
||
onClose={() => setStatusDialog(null)}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-ghost" onClick={() => setStatusDialog(null)}>Abbrechen</button>
|
||
<button className="btn btn-save" onClick={statusSpeichern}>Übernehmen</button>
|
||
</>
|
||
}
|
||
>
|
||
<p className="section-intro">„{statusDialog.wunsch.idea}"</p>
|
||
<div className="field">
|
||
<label>
|
||
Begründung <span className="field-hint">
|
||
steht öffentlich unter dem Wunsch und im Discord-Post — leer lassen geht auch
|
||
{statusDialog.status === 'umgesetzt' && ', kommt zusätzlich in die Nachricht an alle Stimmen-Geber'}
|
||
</span>
|
||
</label>
|
||
<textarea
|
||
rows={3}
|
||
maxLength={500}
|
||
placeholder={statusDialog.status === 'abgelehnt'
|
||
? 'Warum das nicht kommt — das ist die Antwort, die sonst fehlt.'
|
||
: 'Ein, zwei Sätze reichen.'}
|
||
value={statusGrund}
|
||
onChange={(e) => setStatusGrund(e.target.value)}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
|
||
{mergeVon && (
|
||
<Dialog title="Doppler zusammenführen" onClose={() => setMergeVon(null)}>
|
||
<p className="section-intro">
|
||
„{mergeVon.idea}" verschwindet aus der Liste; die Stimmen wandern zum
|
||
gewählten Wunsch. Wer für beide gestimmt hat, zählt dort weiterhin einmal.
|
||
</p>
|
||
<div className="dlg-pick">
|
||
{wuensche.filter((w) => w.id !== mergeVon.id).map((w) => (
|
||
<button key={w.id} onClick={() => zusammenfuehren(mergeVon, w)}>
|
||
{w.idea}
|
||
<span className="pick-hint">👍 {w.score}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Wunsch-Bereiche</h2>
|
||
<p className="section-intro">
|
||
Schubladen für Ideen — „Fahrzeuge", „Wirtschaft", „Technik". Ohne Bereiche
|
||
bleibt die Liste eine Liste; mit ihnen kann man auf der Roadmap filtern, und
|
||
der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem
|
||
Ändern <strong>/wunsch-setup</strong> neu ausführen.
|
||
</p>
|
||
<p className="section-intro">
|
||
Ist der Voting-Kanal ein <strong>Forum</strong>, wird jeder Wunsch ein
|
||
Forums-Beitrag und bekommt Tags — dann kann man auch in Discord danach
|
||
filtern, und der Stand zieht beim Ändern automatisch mit. Der Knopf legt die
|
||
fehlenden Tags an: die Bereiche von hier plus die fünf Stände. Vorhandene
|
||
bleiben, wie sie sind.
|
||
</p>
|
||
<div className="field-row" style={{ marginBottom: '1rem' }}>
|
||
<button className="btn" onClick={forumTags}>Fehlende Tags im Forum anlegen</button>
|
||
</div>
|
||
|
||
{bereiche.length === 0 && <p className="notice">Noch keine Bereiche.</p>}
|
||
|
||
{bereiche.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{bereiche.map((b) => (
|
||
<div className="key-row" key={b.id}>
|
||
<span className="tk-emoji">{b.emoji}</span>
|
||
<span className="key-name">{b.name}</span>
|
||
<span className="key-used">
|
||
{(() => {
|
||
const n = wuensche.filter((w) => w.category_id === b.id).length;
|
||
return `${n} ${n === 1 ? 'Wunsch' : 'Wünsche'}`;
|
||
})()}
|
||
</span>
|
||
<button
|
||
className="btn btn-mini"
|
||
onClick={() => setBereichDraft({ id: b.id, name: b.name, emoji: b.emoji ?? '' })}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button className="card-delete" title="Bereich entfernen" onClick={() => bereichLoeschen(b)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field-row">
|
||
<EmojiPick
|
||
value={bereichDraft.emoji}
|
||
onChange={(v) => setBereichDraft({ ...bereichDraft, emoji: v })}
|
||
placeholder="🚗"
|
||
/>
|
||
<input
|
||
type="text" maxLength={40} placeholder="Name, z. B. Fahrzeuge"
|
||
value={bereichDraft.name}
|
||
onChange={(e) => setBereichDraft({ ...bereichDraft, name: e.target.value })}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') bereichSpeichern(); }}
|
||
/>
|
||
<button className="btn" onClick={bereichSpeichern}>
|
||
{bereichDraft.id ? 'Speichern' : 'Hinzufügen'}
|
||
</button>
|
||
{bereichDraft.id && (
|
||
<button className="btn btn-ghost" onClick={() => setBereichDraft({ id: null, name: '', emoji: '' })}>
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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">// Events</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>
|
||
|
||
<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}>
|
||
<EmojiPick
|
||
value={entry.emoji}
|
||
onChange={(v) => setMenuEntry(i, { emoji: v })}
|
||
placeholder="🎮"
|
||
/>
|
||
<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>
|
||
</>
|
||
);
|
||
|
||
async function bereichSpeichern() {
|
||
try {
|
||
const res = bereichDraft.id
|
||
? await apiPut(`/api/wish-categories/${bereichDraft.id}`, bereichDraft)
|
||
: await apiPost('/api/wish-categories', bereichDraft);
|
||
setBereiche(res.bereiche ?? []);
|
||
setBereichDraft({ id: null, name: '', emoji: '' });
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
/** Fehlende Tags im Forum anlegen — Bereiche plus die fuenf Staende */
|
||
async function forumTags() {
|
||
try {
|
||
const res = await apiPost('/api/wish-categories/tags');
|
||
const teile = [];
|
||
if (res.angelegt.length) teile.push(`${res.angelegt.length} angelegt`);
|
||
if (res.schonDa.length) teile.push(`${res.schonDa.length} gab es schon`);
|
||
if (res.keinPlatz.length) teile.push(`${res.keinPlatz.length} passten nicht mehr rein`);
|
||
flash(`✓ ${teile.join(', ') || 'nichts zu tun'}`);
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
async function bereichLoeschen(b) {
|
||
if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return;
|
||
try {
|
||
const res = await apiDelete(`/api/wish-categories/${b.id}`);
|
||
setBereiche(res.bereiche ?? []);
|
||
if (res.wishes) setWuensche(res.wishes);
|
||
} catch {
|
||
flash('✗ Entfernen fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
/** Status setzen — mit Begruendung, denn genau die fehlt sonst */
|
||
function wunschStatus(wunsch, status) {
|
||
const label = Object.fromEntries(WUNSCH_STATUS)[status];
|
||
setStatusGrund(wunsch.status === status ? (wunsch.status_grund ?? '') : '');
|
||
setStatusDialog({ wunsch, status, label });
|
||
}
|
||
|
||
async function statusSpeichern() {
|
||
const { wunsch, status } = statusDialog;
|
||
try {
|
||
const res = await apiPut(`/api/wishes/${wunsch.id}/status`, { status, grund: statusGrund });
|
||
setWuensche(res.wishes ?? []);
|
||
setStatusDialog(null);
|
||
flash(res.benachrichtigt
|
||
? `✓ Gespeichert — ${res.benachrichtigt} benachrichtigt`
|
||
: '✓ Gespeichert');
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
async function zusammenfuehren(doppler, original) {
|
||
try {
|
||
const res = await apiPost(`/api/wishes/${doppler.id}/merge`, { original: original.id });
|
||
setWuensche(res.wishes ?? []);
|
||
setMergeVon(null);
|
||
flash('✓ Zusammengeführt');
|
||
} catch (e) {
|
||
flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
|
||
}
|
||
}
|
||
|
||
async function wunschLoeschen(wunsch) {
|
||
if (!window.confirm(`„${wunsch.idea}" löschen? Die Stimmen gehen mit.`)) return;
|
||
try {
|
||
const res = await apiDelete(`/api/wishes/${wunsch.id}`);
|
||
setWuensche(res.wishes ?? []);
|
||
} catch {
|
||
flash('✗ Löschen fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function anliegenSpeichern() {
|
||
const body = { ...anliegenDraft };
|
||
try {
|
||
const res = body.id
|
||
? await apiPut(`/api/ticket-categories/${body.id}`, body)
|
||
: await apiPost('/api/ticket-categories', body);
|
||
setAnliegen(res.kategorien ?? []);
|
||
setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
||
setFeedback(body.id ? 'Anliegen gespeichert.' : 'Anliegen angelegt.');
|
||
} catch (e) {
|
||
setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.');
|
||
}
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
|
||
async function anliegenLoeschen(id, name) {
|
||
if (!window.confirm(`„${name}" wirklich entfernen? Offene Tickets bleiben bestehen.`)) return;
|
||
try {
|
||
const res = await apiDelete(`/api/ticket-categories/${id}`);
|
||
setAnliegen(res.kategorien ?? []);
|
||
if (anliegenDraft.id === id) setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
||
} catch {
|
||
setFeedback('Entfernen fehlgeschlagen.');
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
}
|
||
|
||
/** 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 = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Ticket-Anliegen</h2>
|
||
<p className="section-intro">
|
||
Ohne Anliegen hat der Ticket-Post einen Knopf und jedes Ticket sieht gleich aus.
|
||
Mit Anliegen wird daraus ein Auswahlmenü: der Titel steht im Thread-Namen,
|
||
der Einstiegstext gleich im ersten Beitrag, und die hinterlegte Rolle erfährt
|
||
sofort, dass etwas anliegt. Nach dem Ändern <strong>/ticket-setup</strong> neu
|
||
ausführen — der alte Post behält sonst sein altes Menü.
|
||
</p>
|
||
|
||
{anliegen.length === 0 && (
|
||
<p className="notice">Noch keine Anliegen — es bleibt beim einfachen Knopf.</p>
|
||
)}
|
||
|
||
{anliegen.map((k) => (
|
||
<div className="am-row card" key={k.id}>
|
||
<div className="am-head">
|
||
<span className="tk-emoji">{k.emoji}</span>
|
||
<span className="st-name">{k.name}</span>
|
||
{k.ping_role_id && (
|
||
<span className="tm-chip">
|
||
pingt {roleItems.find((r) => r.id === k.ping_role_id)?.label ?? 'Rolle'}
|
||
</span>
|
||
)}
|
||
<button
|
||
className="btn btn-mini"
|
||
style={{ marginLeft: 'auto' }}
|
||
onClick={() => setAnliegenDraft({
|
||
id: k.id, name: k.name, emoji: k.emoji,
|
||
beschreibung: k.beschreibung ?? '',
|
||
ping_role_id: k.ping_role_id ?? '',
|
||
intro: k.intro ?? '',
|
||
})}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button className="card-delete" title="Anliegen entfernen" onClick={() => anliegenLoeschen(k.id, k.name)}>✕</button>
|
||
</div>
|
||
{(k.beschreibung || k.intro) && (
|
||
<div className="am-meta">
|
||
{k.beschreibung && <span>{k.beschreibung}</span>}
|
||
{k.intro && <span className="am-exempt">Einstiegstext hinterlegt</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>
|
||
{anliegenDraft.id ? 'Anliegen bearbeiten' : 'Anliegen hinzufügen'}
|
||
</h3>
|
||
<div className="field">
|
||
<label>Name <span className="field-hint">steht im Menü und im Thread-Namen</span></label>
|
||
<input type="text" maxLength={80} placeholder="z. B. Fehler melden" value={anliegenDraft.name}
|
||
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, name: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Emoji <span className="field-hint">ein Zeichen oder ein Server-Emoji wie <:name:123> — unpassendes wird weggelassen</span></label>
|
||
<EmojiPick
|
||
value={anliegenDraft.emoji}
|
||
onChange={(v) => setAnliegenDraft({ ...anliegenDraft, emoji: v })}
|
||
placeholder="🐛"
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Kurzbeschreibung <span className="field-hint">graue Zeile unter dem Namen im Menü — höchstens 100 Zeichen</span></label>
|
||
<input type="text" maxLength={100} placeholder="Etwas funktioniert nicht wie erwartet" value={anliegenDraft.beschreibung}
|
||
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, beschreibung: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Rolle benachrichtigen <span className="field-hint">wird beim Öffnen im Thread erwähnt — leer = kein Ping</span></label>
|
||
<PickSelect
|
||
items={roleItems}
|
||
value={anliegenDraft.ping_role_id ?? ''}
|
||
onChange={(v) => setAnliegenDraft({ ...anliegenDraft, ping_role_id: v })}
|
||
empty="— kein Ping —"
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Einstiegstext <span className="field-hint">steht im ersten Beitrag — gute Stelle für „was wir wissen müssen"</span></label>
|
||
<textarea rows={3} maxLength={1024} placeholder="Schreib uns bitte gleich dazu: was hast du gemacht, was ist passiert?"
|
||
value={anliegenDraft.intro}
|
||
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, intro: e.target.value })} />
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={anliegenSpeichern}>
|
||
{anliegenDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
|
||
</button>
|
||
{anliegenDraft.id && (
|
||
<button className="btn btn-ghost" onClick={() => setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' })}>
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{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="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>
|
||
</>
|
||
);
|
||
|
||
const tabPlaytester = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Playtester</h2>
|
||
<p className="section-intro">
|
||
Beworben wird sich über ein Formular, angenommen vom Team — wer angenommen
|
||
wird, landet hier mit Rolle. Schlüssel gehen per Direktnachricht raus; kommt
|
||
sie nicht an, bleibt der Schlüssel im Vorrat.
|
||
</p>
|
||
{(() => {
|
||
// Ohne markiertes Formular gibt es keinen Weg herein — das
|
||
// ist der haeufigste Grund, warum sich niemand bewirbt
|
||
const ptForm = appForms.find((f) => f.playtester);
|
||
return ptForm ? (
|
||
<p className="section-intro">
|
||
Aufnahme läuft über <strong>{ptForm.title}</strong>
|
||
{ptForm.message_id ? '' : ' — das Formular ist noch nicht gepostet'}.
|
||
{' '}<button className="btn btn-mini" onClick={() => setTab('bewerbungen')}>Formular öffnen</button>
|
||
</p>
|
||
) : (
|
||
<p className="notice">
|
||
Noch kein Playtester-Formular. Ohne eines kommt niemand herein —
|
||
unter <strong>Bewerbungen</strong> ein Formular anlegen und dort
|
||
„Annahme trägt als Playtester ein" ankreuzen.
|
||
{' '}<button className="btn btn-mini" onClick={() => setTab('bewerbungen')}>Zu den Bewerbungen</button>
|
||
</p>
|
||
);
|
||
})()}
|
||
<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>
|
||
|
||
<div className="stats" style={{ margin: '1.2rem 0' }}>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.playtesters?.length ?? '—'}</span><span className="stat-label">Playtester</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Freie Schlüssel</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||
</div>
|
||
|
||
{(alphaKeys?.playtesters?.length ?? 0) === 0 ? (
|
||
<p className="notice">Noch niemand dabei.</p>
|
||
) : (
|
||
<div className="key-list">
|
||
{alphaKeys.playtesters.map((p) => (
|
||
<div className="key-row" key={p.user_id}>
|
||
<span className="key-name">{p.username}</span>
|
||
<span className="key-scopes">
|
||
{p.key
|
||
? <code className="pt-key">{p.key}</code>
|
||
: <span className="pt-wartet">wartet auf Schlüssel</span>}
|
||
</span>
|
||
<span className="key-used">seit {p.applied_at?.slice(0, 10)}</span>
|
||
{p.key ? (
|
||
<button className="btn btn-mini" title="Schlüssel wieder freigeben" onClick={() => keyZurueck(p.key)}>
|
||
Zurückziehen
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-mini"
|
||
disabled={!alphaKeys.free}
|
||
title={alphaKeys.free ? 'Schlüssel per Direktnachricht schicken' : 'Vorrat ist leer'}
|
||
onClick={() => keyGeben(p.user_id, p.username)}
|
||
>
|
||
Schlüssel geben
|
||
</button>
|
||
)}
|
||
<button className="card-delete" title="Aus dem Programm nehmen" onClick={() => playtesterEntfernen(p.user_id, p.username)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Schlüssel-Vorrat</h2>
|
||
<div className="field-row" style={{ marginBottom: '1rem' }}>
|
||
<button className="btn" onClick={() => setKeyDialog(true)}>
|
||
<IconKey size={14} /> Schlüssel nachlegen
|
||
</button>
|
||
<button
|
||
className="btn"
|
||
disabled={!alphaKeys?.free || !alphaKeys?.playtestersWithout?.length}
|
||
onClick={() => setKeyEmpfaenger('wahl')}
|
||
>
|
||
Gezielt vergeben …
|
||
</button>
|
||
<button
|
||
className="btn btn-save"
|
||
onClick={distributeKeys}
|
||
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||
>
|
||
An alle {alphaKeys?.playtestersWithout?.length ?? 0} Wartenden
|
||
</button>
|
||
</div>
|
||
|
||
{(alphaKeys?.freieKeys?.length ?? 0) === 0 ? (
|
||
<p className="notice">Der Vorrat ist leer.</p>
|
||
) : (
|
||
<div className="key-list">
|
||
{alphaKeys.freieKeys.map((k) => (
|
||
<div className="key-row" key={k}>
|
||
<span className="key-name"><code className="pt-key">{k}</code></span>
|
||
<span className="key-used">frei</span>
|
||
<button className="card-delete" title="Schlüssel löschen" onClick={() => keyLoeschen(k)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{alphaKeys?.assigned?.length > 0 && (
|
||
<>
|
||
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>Vergeben</h3>
|
||
<div className="key-list">
|
||
{alphaKeys.assigned.map((k) => (
|
||
<div className="key-row" key={k.key}>
|
||
<span className="key-name"><code className="pt-key">{k.key}</code></span>
|
||
<span className="key-used">
|
||
→ {alphaKeys.playtesters?.find((p) => p.user_id === k.assigned_to)?.username ?? k.assigned_to}
|
||
{' · '}{k.assigned_at?.slice(0, 10)}
|
||
</span>
|
||
<button className="btn btn-mini" onClick={() => keyZurueck(k.key)}>Zurückziehen</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{keyDialog && (
|
||
<Dialog
|
||
title="Schlüssel nachlegen"
|
||
onClose={() => setKeyDialog(false)}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-ghost" onClick={() => setKeyDialog(false)}>Abbrechen</button>
|
||
<button
|
||
className="btn btn-save"
|
||
disabled={!keyUpload.trim()}
|
||
onClick={async () => { await uploadAlphaKeys(); setKeyDialog(false); }}
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="field">
|
||
<label>
|
||
Schlüssel <span className="field-hint">einer pro Zeile; schon bekannte werden übersprungen</span>
|
||
</label>
|
||
<textarea
|
||
rows={8}
|
||
placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'}
|
||
value={keyUpload}
|
||
onChange={(e) => setKeyUpload(e.target.value)}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
|
||
{keyEmpfaenger && (
|
||
<Dialog title="Wer bekommt einen Schlüssel?" onClose={() => setKeyEmpfaenger(null)}>
|
||
<p className="section-intro">
|
||
{alphaKeys?.free ?? 0} im Vorrat. Der Schlüssel geht sofort als
|
||
Direktnachricht raus — kommt sie nicht an, bleibt er frei.
|
||
</p>
|
||
<div className="dlg-pick">
|
||
{alphaKeys?.playtestersWithout?.map((p) => (
|
||
<button key={p.user_id} onClick={() => keyGeben(p.user_id, p.username)}>
|
||
{p.username}
|
||
<span className="pick-hint">seit {p.applied_at?.slice(0, 10)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
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'}
|
||
{f.playtester ? ' · Playtester' : ''}
|
||
</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 : [''],
|
||
playtester: Boolean(f.playtester),
|
||
})}>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 className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(formDraft.playtester)}
|
||
onChange={(e) => setFormDraft({ ...formDraft, playtester: e.target.checked })}
|
||
/>
|
||
<span>Annahme trägt als Playtester ein</span>
|
||
<span className="field-hint">
|
||
Angenommene landen zusätzlich in der Playtester-Liste und bekommen von dort
|
||
ihren Schlüssel. <strong>/playtester-setup</strong> postet dann diesen Knopf —
|
||
so kann sich niemand mehr selbst aufnehmen. Es kann nur ein Formular sein.
|
||
</span>
|
||
</label>
|
||
</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">
|
||
<EmojiPick
|
||
value={serviceDraft.icon}
|
||
onChange={(v) => setServiceDraft({ ...serviceDraft, icon: v })}
|
||
placeholder="🗂️"
|
||
/>
|
||
<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">// Sprachkanäle</h2>
|
||
<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">
|
||
{games.find((g) => g.id === s.type)?.name ?? s.type}
|
||
{s.token ? ' 🔑' : ''}
|
||
</span>
|
||
{/* gamedig-Server haben keine URL, sondern Host + Port */}
|
||
<span className="key-used" title={s.type}>
|
||
{s.query_url || [s.host, s.port].filter(Boolean).join(':')}
|
||
</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 ?? '', token: s.token ?? '',
|
||
})}>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
|
||
games={games}
|
||
value={serverDraft.type}
|
||
onChange={spielWaehlen}
|
||
/>
|
||
{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 — oder ganze Abfrage-URL einfügen" title="Du kannst hier auch die komplette Abfrage-URL einfügen; Host, Port und Code werden herausgezogen." value={serverDraft.host} onChange={(e) => hostEingeben(e.target.value)} />
|
||
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder={gewaehltesSpiel?.port ? `Port ${gewaehltesSpiel.port}` : 'Port'} value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
|
||
</>
|
||
)}
|
||
</div>
|
||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||
{gewaehltesSpiel
|
||
? <>Gewählt: <b>{gewaehltesSpiel.name}</b>{!gewaehltesSpiel.eigen && gewaehltesSpiel.port ? ` — Standard-Query-Port ${gewaehltesSpiel.port}, Hoster weichen oft davon ab` : ''}. Die Liste kommt direkt aus gamedig, {games.length} Einträge.</>
|
||
: <>Unbekannte ID — sie wird trotzdem versucht. Alle bekannten stehen in der <a href="https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Liste</a>.</>}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Nur bei Spielen, die ohne Code gar nicht erst antworten */}
|
||
{gewaehltesSpiel?.token && (
|
||
<div className="field">
|
||
<label>
|
||
Zugangs-Code {gewaehltesSpiel.token === 'optional' && <span className="field-hint">optional</span>}
|
||
</label>
|
||
<input
|
||
type="password" autoComplete="off"
|
||
placeholder={gewaehltesSpiel.token === 'pflicht' ? 'Pflicht für dieses Spiel' : 'leer lassen, wenn nicht nötig'}
|
||
value={serverDraft.token}
|
||
onChange={(e) => setServerDraft({ ...serverDraft, token: e.target.value })}
|
||
/>
|
||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||
{gewaehltesSpiel.id.startsWith('farmingsimulator')
|
||
? <>Im Spiel heißt er <b>Code</b> und steckt in der Abfrage-URL, die dein Hoster ausgibt: <code>…/feed/dedicated-server-stats.xml?code=<b>DAS_HIER</b></code>. Am einfachsten die ganze URL oben ins Host-Feld einfügen — Host, Port und Code werden herausgezogen.</>
|
||
: <>Steht im Webinterface des Servers. Ohne ihn liefert die Abfrage nichts und der Server stünde dauerhaft auf „offline".</>}
|
||
</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,
|
||
playtester: tabPlaytester,
|
||
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>
|
||
</>
|
||
);
|
||
}
|