Der Discord-Login ist jetzt für alle Member nützlich: - /profil: Rang + XP-Fortschritt, Discord-Rollen als Chips, Playtester- Badge, eigener Alpha-Key mit Kopier-Button - Rollen-Selfservice: veröffentlichte Rollen-Menüs direkt im Browser togglen (gleiche Exklusiv-Logik wie die Discord-Buttons; nur Rollen aus aktiven Menüs erlaubt) - Wunsch-Voting im Web: auf /roadmap Wünsche einreichen (postet wie /wunsch in den Voting-Kanal) und upvoten (wish_votes-Tabelle, ein Vote pro Member, unabhängig von Discord-👍) - Member-Gate: Login nur für Mitglieder der konfigurierten Guild; Abgewiesene sehen einen Banner mit Discord-Invite-Link (neue Settings member_gate_enabled + discord_invite_url im System-Tab) - Membership-Check mit 5-Minuten-Cache, Owner immer erlaubt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1521 lines
81 KiB
React
1521 lines
81 KiB
React
import { useEffect, useRef, useState } from 'react';
|
||
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
|
||
import EmbedComposer from '../components/EmbedComposer.jsx';
|
||
|
||
const API_SCOPES = [
|
||
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
||
{ id: 'dm', label: 'dm', hint: 'Direktnachrichten senden' },
|
||
{ id: 'roles', label: 'roles', hint: 'Rollen geben/nehmen' },
|
||
{ id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' },
|
||
];
|
||
|
||
const TABS = [
|
||
{ id: 'status', icon: '📊', label: 'Status' },
|
||
{ id: 'brand', icon: '🎨', label: 'Brand' },
|
||
{ id: 'feeds', icon: '📔', label: 'Feeds' },
|
||
{ id: 'community', icon: '🙌', label: 'Community' },
|
||
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
||
{ id: 'support', icon: '📬', label: 'Support' },
|
||
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
||
{ id: 'server', icon: '🎮', label: 'Server' },
|
||
{ id: 'system', icon: '⚙️', label: 'System' },
|
||
{ id: 'api', icon: '🔑', label: 'API' },
|
||
{ id: 'team', icon: '👥', label: 'Team' },
|
||
];
|
||
|
||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||
const TAB_SCOPES = {
|
||
status: 'any',
|
||
brand: null,
|
||
feeds: 'settings',
|
||
community: 'community',
|
||
rollen: 'rollen',
|
||
support: 'settings',
|
||
bewerbungen: 'bewerbungen',
|
||
composer: 'content',
|
||
server: 'server',
|
||
system: null,
|
||
api: null,
|
||
team: null,
|
||
};
|
||
|
||
const TEAM_SCOPE_LABELS = {
|
||
content: 'Composer, Tags, Triggers & geplante Posts',
|
||
community: 'Playtester & Alpha-Keys',
|
||
rollen: 'Rollen-Menüs',
|
||
bewerbungen: 'Bewerbungen',
|
||
server: 'Game-Server',
|
||
settings: 'Kanäle & Feed-Einstellungen',
|
||
devlogs: 'Devlogs löschen',
|
||
};
|
||
|
||
/** Sekunden → "2d 4h 13m" */
|
||
/** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon.
|
||
* Filtert die Presets beim Tippen, freie gamedig-IDs bleiben erlaubt. */
|
||
function GameSelect({ presets, value, onChange }) {
|
||
const [open, setOpen] = useState(false);
|
||
const wrapRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
function onDocClick(e) {
|
||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||
}
|
||
document.addEventListener('mousedown', onDocClick);
|
||
return () => document.removeEventListener('mousedown', onDocClick);
|
||
}, []);
|
||
|
||
const q = value.trim().toLowerCase();
|
||
const filtered = presets.filter(([id, label]) =>
|
||
!q || id.includes(q) || label.toLowerCase().includes(q)
|
||
);
|
||
|
||
return (
|
||
<div className="gamesel" ref={wrapRef} style={{ flex: 'none', width: '13rem' }}>
|
||
<input
|
||
type="text" placeholder="Spiel (gamedig-ID)"
|
||
title="Vorschläge oder beliebige gamedig-Spiel-ID"
|
||
value={value}
|
||
onFocus={() => setOpen(true)}
|
||
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
|
||
onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }}
|
||
/>
|
||
<span className="gamesel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>▾</span>
|
||
{open && filtered.length > 0 && (
|
||
<div className="gamesel-list">
|
||
{filtered.map(([id, label]) => (
|
||
<button
|
||
type="button" key={id}
|
||
className={`gamesel-item${id === value ? ' active' : ''}`}
|
||
onMouseDown={(e) => { e.preventDefault(); onChange(id); setOpen(false); }}
|
||
>
|
||
<span className="gamesel-label">{label}</span>
|
||
<span className="gamesel-id">{id}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fmtUptime(s) {
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
|
||
}
|
||
|
||
function fmtBytes(b) {
|
||
if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||
return `${(b / 1024).toFixed(0)} KB`;
|
||
}
|
||
|
||
export default function Settings({ me }) {
|
||
const [tab, setTab] = useState('status');
|
||
const [data, setData] = useState(null);
|
||
const [form, setForm] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [feedback, setFeedback] = useState(null);
|
||
// Community
|
||
const [playtesters, setPlaytesters] = useState([]);
|
||
// Rollen-Menüs
|
||
const emptyMenu = {
|
||
id: null, title: '', description: '', channel_id: '', exclusive: false,
|
||
entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
|
||
};
|
||
const [menus, setMenus] = useState([]);
|
||
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||
// Branding
|
||
const [giteaToken, setGiteaToken] = useState('');
|
||
const [botName, setBotName] = useState('');
|
||
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
|
||
// Alpha-Keys
|
||
const [alphaKeys, setAlphaKeys] = useState(null);
|
||
const [keyUpload, setKeyUpload] = useState('');
|
||
// Bewerbungs-Formulare
|
||
const emptyForm = {
|
||
id: null, title: '', description: '', review_channel_id: '',
|
||
approve_role_id: '', post_channel_id: '', questions: [''],
|
||
};
|
||
const [appForms, setAppForms] = useState([]);
|
||
const [formDraft, setFormDraft] = useState(emptyForm);
|
||
// Triggers
|
||
const [triggers, setTriggers] = useState([]);
|
||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||
// Game-Server
|
||
const GAME_PRESETS = [
|
||
['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'],
|
||
['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'],
|
||
['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'],
|
||
['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'],
|
||
['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'],
|
||
['garrysmod', '🔧 Garry’s Mod'], ['satisfactory', '🏭 Satisfactory'],
|
||
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
|
||
['unturned', '🏝️ Unturned'],
|
||
];
|
||
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '' };
|
||
const [servers, setServers] = useState([]);
|
||
const [serverDraft, setServerDraft] = useState(emptyServer);
|
||
// Bot-Beschreibung + Team
|
||
const [botDesc, setBotDesc] = useState('');
|
||
const [team, setTeam] = useState([]);
|
||
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||
|
||
const canTab = (id) => {
|
||
if (me.admin) return true;
|
||
const need = TAB_SCOPES[id];
|
||
if (need === 'any') return (me.scopes?.length ?? 0) > 0;
|
||
return need ? me.scopes?.includes(need) : false;
|
||
};
|
||
const visibleTabs = TABS.filter((t) => canTab(t.id));
|
||
// Tags + geplante Posts
|
||
const [tags, setTags] = useState([]);
|
||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||
const [scheduled, setScheduled] = useState([]);
|
||
const [schedDraft, setSchedDraft] = useState({
|
||
channel_id: '', embed_title: '', content: '', type: 'once',
|
||
date: '', time: '18:00', weekday: '5',
|
||
});
|
||
// API-Keys
|
||
const [keys, setKeys] = useState([]);
|
||
const [newKeyName, setNewKeyName] = useState('');
|
||
const [newKeyScopes, setNewKeyScopes] = useState(['message']);
|
||
const [createdKey, setCreatedKey] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!me.admin && !(me.scopes?.length > 0)) return;
|
||
apiGet('/api/settings')
|
||
.then((d) => {
|
||
setData(d);
|
||
setForm(d.settings);
|
||
})
|
||
.catch((e) => setError(e.status));
|
||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||
}, [me.admin, me.scopes?.length]);
|
||
|
||
useEffect(() => {
|
||
if (data?.status?.botName) setBotName(data.status.botName);
|
||
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
||
}, [data]);
|
||
|
||
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
|
||
useEffect(() => {
|
||
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
|
||
}, [me.admin, me.scopes]);
|
||
|
||
async function saveTeam() {
|
||
if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) {
|
||
flash('✗ User-ID und mindestens ein Bereich nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPut('/api/webadmins', teamDraft);
|
||
setTeam(res.admins);
|
||
setTeamDraft({ user_id: '', scopes: [] });
|
||
flash('✓ Team-Mitglied gespeichert');
|
||
} catch {
|
||
flash('✗ Fehlgeschlagen — User-ID prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeTeam(userId) {
|
||
if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return;
|
||
const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null);
|
||
if (res) setTeam(res.admins);
|
||
}
|
||
|
||
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
|
||
async function saveServer() {
|
||
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
|
||
flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig');
|
||
return;
|
||
}
|
||
try {
|
||
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
|
||
else await apiPost('/api/gameservers', serverDraft);
|
||
setServers((await apiGet('/api/gameservers')).servers);
|
||
setServerDraft(emptyServer);
|
||
flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen (URL mit http/https?)');
|
||
}
|
||
}
|
||
|
||
async function removeServer(id) {
|
||
if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return;
|
||
await apiDelete(`/api/gameservers/${id}`).catch(() => {});
|
||
setServers((await apiGet('/api/gameservers')).servers);
|
||
if (serverDraft.id === id) setServerDraft(emptyServer);
|
||
}
|
||
|
||
async function uploadAlphaKeys() {
|
||
if (!keyUpload.trim()) return;
|
||
try {
|
||
const res = await apiPost('/api/alphakeys', { keys: keyUpload });
|
||
setAlphaKeys(res);
|
||
setKeyUpload('');
|
||
flash(`✓ ${res.added} Keys hinzugefügt`);
|
||
} catch {
|
||
flash('✗ Upload fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function distributeKeys() {
|
||
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
|
||
try {
|
||
const res = await apiPost('/api/alphakeys/distribute');
|
||
setAlphaKeys(res);
|
||
flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`);
|
||
} catch {
|
||
flash('✗ Verteilung fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function saveForm(publish = false) {
|
||
const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean);
|
||
if (!formDraft.title.trim() || questions.length === 0) {
|
||
flash('✗ Titel und mindestens eine Frage nötig');
|
||
return;
|
||
}
|
||
try {
|
||
const body = { ...formDraft, questions };
|
||
const res = formDraft.id
|
||
? await apiPut(`/api/appforms/${formDraft.id}`, body)
|
||
: await apiPost('/api/appforms', body);
|
||
if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`);
|
||
setAppForms((await apiGet('/api/appforms')).forms);
|
||
setFormDraft(emptyForm);
|
||
flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert');
|
||
} catch {
|
||
flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeForm(id) {
|
||
if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return;
|
||
await apiDelete(`/api/appforms/${id}`).catch(() => {});
|
||
setAppForms((await apiGet('/api/appforms')).forms);
|
||
if (formDraft.id === id) setFormDraft(emptyForm);
|
||
}
|
||
|
||
async function saveTriggerDraft() {
|
||
if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) {
|
||
flash('✗ Keyword (min 3 Zeichen) und Antwort nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPut('/api/triggers', triggerDraft);
|
||
setTriggers((await apiGet('/api/triggers')).triggers);
|
||
setTriggerDraft({ keyword: '', reply: '' });
|
||
flash('✓ Trigger gespeichert');
|
||
} catch {
|
||
flash('✗ Trigger fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function removeTrigger(keyword) {
|
||
await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {});
|
||
setTriggers((await apiGet('/api/triggers')).triggers);
|
||
}
|
||
|
||
async function uploadImage(target, file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
try {
|
||
await apiPost(`/api/branding/${target}`, { data: reader.result });
|
||
flash(`✓ ${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`);
|
||
} catch {
|
||
flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)');
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function saveBrand() {
|
||
try {
|
||
const body = { ...form, bot_name: botName, bot_description: botDesc };
|
||
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||
const res = await apiPut('/api/settings', body);
|
||
setForm(res.settings);
|
||
setGiteaToken('');
|
||
setTwitchCreds({ id: '', secret: '' });
|
||
flash('✓ Gespeichert & angewendet');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)');
|
||
}
|
||
}
|
||
|
||
async function saveTagDraft() {
|
||
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
|
||
flash('✗ Name und Inhalt nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content });
|
||
setTags((await apiGet('/api/tags')).tags);
|
||
setTagDraft({ name: '', content: '' });
|
||
flash('✓ Tag gespeichert');
|
||
} catch {
|
||
flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)');
|
||
}
|
||
}
|
||
|
||
async function removeTag(name) {
|
||
if (!window.confirm(`Tag „${name}" löschen?`)) return;
|
||
await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {});
|
||
setTags((await apiGet('/api/tags')).tags);
|
||
}
|
||
|
||
async function createSchedule() {
|
||
if (!schedDraft.channel_id || !schedDraft.content.trim()) {
|
||
flash('✗ Kanal und Text nötig');
|
||
return;
|
||
}
|
||
try {
|
||
await apiPost('/api/scheduled', schedDraft);
|
||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||
setSchedDraft({ ...schedDraft, content: '', embed_title: '' });
|
||
flash('✓ Post geplant');
|
||
} catch {
|
||
flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeSchedule(id) {
|
||
await apiDelete(`/api/scheduled/${id}`).catch(() => {});
|
||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||
}
|
||
|
||
function flash(msg) {
|
||
setFeedback(msg);
|
||
setTimeout(() => setFeedback(null), 3000);
|
||
}
|
||
|
||
async function save() {
|
||
try {
|
||
const body = { ...form };
|
||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||
const res = await apiPut('/api/settings', body);
|
||
setForm(res.settings);
|
||
setTwitchCreds({ id: '', secret: '' });
|
||
flash('✓ Gespeichert');
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function sendTest(target) {
|
||
try {
|
||
await apiPost(`/api/settings/test/${target}`);
|
||
flash('✓ Test-Nachricht gesendet');
|
||
} catch {
|
||
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
|
||
}
|
||
}
|
||
|
||
async function createKey() {
|
||
if (!newKeyName.trim() || newKeyScopes.length === 0) return;
|
||
try {
|
||
const res = await apiPost('/api/apikeys', { name: newKeyName.trim(), scopes: newKeyScopes });
|
||
setCreatedKey(res);
|
||
setNewKeyName('');
|
||
setKeys((await apiGet('/api/apikeys')).keys);
|
||
} catch {
|
||
flash('✗ Key-Erstellung fehlgeschlagen');
|
||
}
|
||
}
|
||
|
||
async function revokeKey(id) {
|
||
if (!window.confirm('Diesen API-Key widerrufen? Skripte mit diesem Key verlieren sofort den Zugriff.')) return;
|
||
await apiDelete(`/api/apikeys/${id}`).catch(() => {});
|
||
setKeys((await apiGet('/api/apikeys')).keys);
|
||
if (createdKey?.id === id) setCreatedKey(null);
|
||
}
|
||
|
||
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
|
||
|
||
async function saveMenu(publish = false) {
|
||
const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id);
|
||
if (!menuDraft.title.trim() || entries.length === 0) {
|
||
flash('✗ Titel und mindestens eine Rolle nötig');
|
||
return;
|
||
}
|
||
if (publish && !menuDraft.channel_id) {
|
||
flash('✗ Zum Posten einen Kanal wählen');
|
||
return;
|
||
}
|
||
try {
|
||
const body = { ...menuDraft, entries };
|
||
const res = menuDraft.id
|
||
? await apiPut(`/api/rolemenus/${menuDraft.id}`, body)
|
||
: await apiPost('/api/rolemenus', body);
|
||
let menu = res.menu;
|
||
if (publish) {
|
||
await apiPost(`/api/rolemenus/${menu.id}/publish`);
|
||
flash('✓ Menü in Discord gepostet');
|
||
} else {
|
||
flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert');
|
||
}
|
||
await reloadMenus();
|
||
setMenuDraft(emptyMenu);
|
||
} catch {
|
||
flash('✗ Speichern fehlgeschlagen — Einträge prüfen');
|
||
}
|
||
}
|
||
|
||
async function removeMenu(id) {
|
||
if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return;
|
||
await apiDelete(`/api/rolemenus/${id}`).catch(() => {});
|
||
if (menuDraft.id === id) setMenuDraft(emptyMenu);
|
||
await reloadMenus();
|
||
}
|
||
|
||
const editMenu = (m) =>
|
||
setMenuDraft({
|
||
id: m.id, title: m.title, description: m.description,
|
||
channel_id: m.channel_id ?? '', exclusive: m.exclusive,
|
||
entries: m.entries.length
|
||
? m.entries.map((e) => ({ style: 'secondary', ...e }))
|
||
: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
|
||
});
|
||
|
||
const setMenuEntry = (i, patch) =>
|
||
setMenuDraft({
|
||
...menuDraft,
|
||
entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)),
|
||
});
|
||
|
||
const header = (
|
||
<section className="page-header">
|
||
<div className="page-bg-text">SETUP</div>
|
||
<div className="page-header-grid" aria-hidden="true" />
|
||
<div className="page-header-content">
|
||
<div className="page-tag">// Admin</div>
|
||
<h1 className="page-title">Setup</h1>
|
||
<p className="page-subtitle">Alles an einem Ort — Änderungen greifen sofort.</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
|
||
return (
|
||
<>
|
||
{header}
|
||
<section className="content">
|
||
<div className="notice center">
|
||
<p>🔒 Nur für den Admin.</p>
|
||
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
|
||
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||
|
||
const channelOptions = data.channels.map((c) => (
|
||
<option key={c.id} value={c.id}>#{c.name} — {c.guild}</option>
|
||
));
|
||
const roleOptions = (data.roles ?? []).map((r) => (
|
||
<option key={r.id} value={r.id}>@{r.name} — {r.guild}</option>
|
||
));
|
||
const channelName = (id) => {
|
||
const c = data.channels.find((x) => x.id === id);
|
||
return c ? `#${c.name}` : id;
|
||
};
|
||
|
||
/* ── Tab-Inhalte ───────────────────────────────── */
|
||
|
||
const tabStatus = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Status</h2>
|
||
<div className="stats">
|
||
<div className="stat"><span className="stat-value">{data.status.botTag ?? '—'}</span><span className="stat-label">Bot</span></div>
|
||
<div className="stat"><span className="stat-value">{fmtUptime(data.status.uptimeSeconds)}</span><span className="stat-label">Uptime</span></div>
|
||
<div className="stat"><span className="stat-value">{data.status.devlogs}</span><span className="stat-label">Devlogs</span></div>
|
||
<div className="stat"><span className="stat-value">{data.status.commits}</span><span className="stat-label">Commits</span></div>
|
||
<div className="stat"><span className="stat-value">{fmtBytes(data.status.dbSizeBytes)}</span><span className="stat-label">Datenbank</span></div>
|
||
<div className="stat">
|
||
<span className="stat-value">{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}</span>
|
||
<span className="stat-label">Letztes Backup</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
||
const tabBrand = (
|
||
<>
|
||
<div className="settings-section bot-card">
|
||
<div className="bot-card-left">
|
||
<div className="bot-avatar-wrap">
|
||
{data.status.botAvatar
|
||
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
|
||
: <div className="bot-avatar bot-avatar-empty">🤖</div>}
|
||
<span className="bot-dot" style={{ background: statusDot }} />
|
||
</div>
|
||
<label className="btn btn-upload">
|
||
⬆ Bild ändern
|
||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||
</label>
|
||
<label className="btn btn-upload btn-ghost">
|
||
🖼 Banner
|
||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||
</label>
|
||
</div>
|
||
<div className="bot-card-right">
|
||
<div className="field">
|
||
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Beschreibung <span className="field-hint">„Über mich" im Bot-Profil (max 400 Zeichen)</span></label>
|
||
<textarea rows={3} maxLength={400} placeholder="z. B. Community-Bot für EcoGame — Devlogs, Level, Tickets & mehr. 🤖" value={botDesc} onChange={(e) => setBotDesc(e.target.value)} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Bot-Status</label>
|
||
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||
<option value="online">🟢 Online</option>
|
||
<option value="idle">🌙 Abwesend</option>
|
||
<option value="dnd">⛔ Bitte nicht stören</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Aktivität <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
||
<div className="field-row">
|
||
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
||
<option value="custom">Status</option>
|
||
<option value="playing">Spielt</option>
|
||
<option value="watching">Schaut</option>
|
||
<option value="listening">Hört</option>
|
||
</select>
|
||
<input type="text" placeholder="z. B. ⛏️ EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Embeds</h2>
|
||
<div className="field">
|
||
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
|
||
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Embed-Farben <span className="field-hint">primär (Embeds) · sekundär (Releases, Mod-Aktionen)</span></label>
|
||
<div className="field-row">
|
||
<input type="color" className="color-pick" value={form.brand_color} onChange={(e) => setForm({ ...form, brand_color: e.target.value })} />
|
||
<input type="color" className="color-pick" value={form.brand_color2} onChange={(e) => setForm({ ...form, brand_color2: e.target.value })} />
|
||
<span className="field-hint" style={{ alignSelf: 'center' }}>{form.brand_color} · {form.brand_color2}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Aus der Env verlagert</h2>
|
||
<div className="field">
|
||
<label>Guild-ID <span className="field-hint">für sofortige Slash-Commands; greift beim nächsten Neustart</span></label>
|
||
<input type="text" placeholder="Server-ID" value={form.discord_guild_id} onChange={(e) => setForm({ ...form, discord_guild_id: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Gitea-API-Token <span className="field-hint">{form.gitea_api_token_set ? '✓ gesetzt — neuen Wert eingeben zum Ersetzen' : 'für /bug; wird nie wieder angezeigt'}</span></label>
|
||
<input type="password" placeholder="Token (write-only)" value={giteaToken} onChange={(e) => setGiteaToken(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={saveBrand}>Speichern & anwenden</button>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabFeeds = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Kanäle</h2>
|
||
<div className="field">
|
||
<label>Devlog-Kanal <span className="field-hint">öffentlich — hier postet der Bot die Devlogs</span></label>
|
||
<div className="field-row">
|
||
<select value={form.devlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}>
|
||
<option value="" disabled>— Kanal wählen —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Commit-Kanal <span className="field-hint">privat — hier landen die Push-Embeds</span></label>
|
||
<div className="field-row">
|
||
<select value={form.commit_channel_id ?? ''} onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}>
|
||
<option value="" disabled>— Kanal wählen —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<button className="btn" onClick={() => sendTest('commit')}>Test</button>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases — leer = aus</span></label>
|
||
<div className="field-row">
|
||
<select value={form.release_channel_id ?? ''} onChange={(e) => setForm({ ...form, release_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<button className="btn" onClick={() => sendTest('release')}>Test</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Devlog</h2>
|
||
<div className="field">
|
||
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post — leer = kein Ping</span></label>
|
||
<select value={form.devlog_ping_role_id ?? ''} onChange={(e) => setForm({ ...form, devlog_ping_role_id: e.target.value })}>
|
||
<option value="">— kein Ping —</option>
|
||
{roleOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.weekly_recap_enabled} onChange={(e) => setForm({ ...form, weekly_recap_enabled: e.target.checked })} />
|
||
<span>Wochen-Rückblick (sonntags 20:00)</span>
|
||
<span className="field-hint">Commits pro Tag, Devlogs & Top-Projekte</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.devlog_threads_enabled} onChange={(e) => setForm({ ...form, devlog_threads_enabled: e.target.checked })} />
|
||
<span>Diskussions-Thread unter jedem Devlog</span>
|
||
<span className="field-hint">„💬 Devlog <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">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.commit_feed_enabled} onChange={(e) => setForm({ ...form, commit_feed_enabled: e.target.checked })} />
|
||
<span>Push-Embeds posten</span>
|
||
<span className="field-hint">aus = weiter archivieren, nur nicht posten</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle</span></label>
|
||
<input type="text" placeholder="z. B. main, release" value={form.commit_branch_filter} onChange={(e) => setForm({ ...form, commit_branch_filter: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen</span></label>
|
||
<input type="text" placeholder="z. B. D4rkst3r/d4rkbot" value={form.ignored_repos} onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabCommunity = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Playtester</h2>
|
||
<div className="field">
|
||
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
|
||
<select value={form.playtester_role_id ?? ''} onChange={(e) => setForm({ ...form, playtester_role_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{roleOptions}
|
||
</select>
|
||
</div>
|
||
{playtesters.length > 0 && (
|
||
<div className="key-list">
|
||
{playtesters.map((p) => (
|
||
<div className="key-row" key={p.user_id}>
|
||
<span className="key-name">{p.username}</span>
|
||
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Alpha-Keys</h2>
|
||
<div className="stats" style={{ marginBottom: '1.2rem' }}>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Frei</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.assigned?.length ?? '—'}</span><span className="stat-label">Vergeben</span></div>
|
||
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Keys hinzufügen <span className="field-hint">einer pro Zeile</span></label>
|
||
<textarea rows={3} placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'} value={keyUpload} onChange={(e) => setKeyUpload(e.target.value)} />
|
||
</div>
|
||
<div className="field-row">
|
||
<button className="btn" onClick={uploadAlphaKeys}>Hinzufügen</button>
|
||
<button
|
||
className="btn btn-save"
|
||
onClick={distributeKeys}
|
||
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||
>
|
||
🎟️ An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
|
||
</button>
|
||
</div>
|
||
{alphaKeys?.assigned?.length > 0 && (
|
||
<div className="key-list" style={{ marginTop: '1rem' }}>
|
||
{alphaKeys.assigned.map((k) => (
|
||
<div className="key-row" key={k.key}>
|
||
<span className="key-name">{k.key}</span>
|
||
<span className="key-used">→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Starboard & Galerie</h2>
|
||
<div className="field">
|
||
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
|
||
<div className="field-row">
|
||
<select value={form.starboard_channel_id ?? ''} onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<input type="text" style={{ width: '6rem', flex: 'none' }} title="Schwellwert (Anzahl ⭐)" value={form.starboard_threshold} onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label>
|
||
<select value={form.screenshot_channel_id ?? ''} onChange={(e) => setForm({ ...form, screenshot_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label>
|
||
<select value={form.voting_channel_id ?? ''} onChange={(e) => setForm({ ...form, voting_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Level-System</h2>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.levels_enabled} onChange={(e) => setForm({ ...form, levels_enabled: e.target.checked })} />
|
||
<span>XP fürs Schreiben vergeben</span>
|
||
<span className="field-hint">15–25 XP pro Nachricht, 60s Cooldown; /rank + Bestenliste auf /level</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.levels_announce} onChange={(e) => setForm({ ...form, levels_announce: e.target.checked })} />
|
||
<span>Level-Up im Kanal feiern</span>
|
||
<span className="field-hint">„🎉 Name ist jetzt Level 5!"</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label>Rollen-Belohnungen <span className="field-hint">Format: Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678</span></label>
|
||
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabRollen = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Rollen bei Join</h2>
|
||
<div className="field">
|
||
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch — leer = aus</span></label>
|
||
<select value={form.autorole_id ?? ''} onChange={(e) => setForm({ ...form, autorole_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{roleOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.sticky_roles_enabled} onChange={(e) => setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
|
||
<span>Sticky-Roles</span>
|
||
<span className="field-hint">wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder</span>
|
||
</label>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||
<p className="section-intro">
|
||
Selbstbedienungs-Rollen wie bei Carl-bot — nur ohne Paywall. 😄 Der Bot postet
|
||
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
|
||
jederzeit editierbar, der Discord-Post zieht automatisch mit.
|
||
</p>
|
||
|
||
{menus.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{menus.map((m) => (
|
||
<div className="key-row" key={m.id}>
|
||
<span className="key-name">{m.title}</span>
|
||
<span className="key-scopes">
|
||
{m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
|
||
{m.exclusive ? ' · exklusiv' : ''}
|
||
</span>
|
||
<span className="key-used">
|
||
{m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
|
||
</span>
|
||
<button className="btn btn-mini" onClick={() => editMenu(m)}>Bearbeiten</button>
|
||
<button className="card-delete" title="Menü + Discord-Post löschen" onClick={() => removeMenu(m.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="Titel, z. B. 🎭 Hol dir deine Rollen" value={menuDraft.title} onChange={(e) => setMenuDraft({ ...menuDraft, title: e.target.value })} />
|
||
<select value={menuDraft.channel_id} onChange={(e) => setMenuDraft({ ...menuDraft, channel_id: e.target.value })}>
|
||
<option value="">— Kanal wählen —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Beschreibung <span className="field-hint">optional, steht im Embed über den Buttons</span></label>
|
||
<input type="text" placeholder="z. B. Wähl aus, wofür du Pings bekommen willst" value={menuDraft.description} onChange={(e) => setMenuDraft({ ...menuDraft, description: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={menuDraft.exclusive} onChange={(e) => setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
|
||
<span>Exklusiv</span>
|
||
<span className="field-hint">max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label>Rollen <span className="field-hint">Emoji optional (auch Custom als <:name:id>), Label = Button-Text</span></label>
|
||
{menuDraft.entries.map((entry, i) => (
|
||
<div className="rr-row" key={i}>
|
||
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
|
||
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
|
||
<select value={entry.role_id} onChange={(e) => setMenuEntry(i, { role_id: e.target.value })}>
|
||
<option value="" disabled>— Rolle —</option>
|
||
{roleOptions}
|
||
</select>
|
||
<select
|
||
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
|
||
title="Button-Farbe"
|
||
value={entry.style ?? 'secondary'}
|
||
onChange={(e) => setMenuEntry(i, { style: e.target.value })}
|
||
>
|
||
<option value="secondary">⬜ Grau</option>
|
||
<option value="primary">🟦 Blau</option>
|
||
<option value="success">🟩 Grün</option>
|
||
<option value="danger">🟥 Rot</option>
|
||
</select>
|
||
<button
|
||
className="card-delete"
|
||
title="Zeile entfernen"
|
||
onClick={() => setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
|
||
disabled={menuDraft.entries.length === 1}
|
||
>✕</button>
|
||
</div>
|
||
))}
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn" onClick={() => setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
|
||
+ Rolle
|
||
</button>
|
||
<button className="btn" onClick={() => saveMenu(false)}>Speichern</button>
|
||
<button className="btn btn-save" onClick={() => saveMenu(true)}>Speichern + posten</button>
|
||
{menuDraft.id && (
|
||
<button className="btn btn-ghost" onClick={() => setMenuDraft(emptyMenu)}>Abbrechen</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabSupport = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Moderation & Kontakt</h2>
|
||
<div className="field">
|
||
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads</span></label>
|
||
<select value={form.modmail_channel_id ?? ''} onChange={(e) => setForm({ ...form, modmail_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label>
|
||
<select value={form.ticket_channel_id ?? ''} onChange={(e) => setForm({ ...form, ticket_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
|
||
<select value={form.welcome_channel_id ?? ''} onChange={(e) => setForm({ ...form, welcome_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
|
||
<select value={form.modlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, modlog_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const tabBewerbungen = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Bewerbungs-Formulare</h2>
|
||
<p className="section-intro">
|
||
Button-Post → Discord-Formular (bis 5 Fragen) → Review mit ✅/❌ im Staff-Kanal.
|
||
Annahme vergibt optional eine Rolle — z. B. für FiveM-Whitelist oder Team-Bewerbungen.
|
||
</p>
|
||
|
||
{appForms.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{appForms.map((f) => (
|
||
<div className="key-row" key={f.id}>
|
||
<span className="key-name">{f.title}</span>
|
||
<span className="key-scopes">{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}</span>
|
||
<button className="btn btn-mini" onClick={() => setFormDraft({
|
||
id: f.id, title: f.title, description: f.description,
|
||
review_channel_id: f.review_channel_id ?? '', approve_role_id: f.approve_role_id ?? '',
|
||
post_channel_id: f.post_channel_id ?? '',
|
||
questions: f.questions.length ? f.questions : [''],
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Formular + Post löschen" onClick={() => removeForm(f.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{formDraft.id ? `Formular #${formDraft.id} bearbeiten` : 'Neues Formular'}</label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="Titel, z. B. 🎮 FiveM-Whitelist" value={formDraft.title} onChange={(e) => setFormDraft({ ...formDraft, title: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<input type="text" placeholder="Beschreibung überm Button (optional)" value={formDraft.description} onChange={(e) => setFormDraft({ ...formDraft, description: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
|
||
<option value="">— Post-Kanal (öffentlich) —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
|
||
<option value="">— Review-Kanal (Staff) —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
|
||
<option value="">— Rolle bei Annahme —</option>
|
||
{roleOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Fragen <span className="field-hint">max 5 (Discord-Modal-Limit)</span></label>
|
||
{formDraft.questions.map((q, i) => (
|
||
<div className="rr-row" key={i}>
|
||
<input type="text" placeholder={`Frage ${i + 1}`} maxLength={45} value={q} onChange={(e) => setFormDraft({
|
||
...formDraft,
|
||
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
|
||
})} />
|
||
<button className="card-delete" title="Frage entfernen" disabled={formDraft.questions.length === 1}
|
||
onClick={() => setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}>✕</button>
|
||
</div>
|
||
))}
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn" disabled={formDraft.questions.length >= 5}
|
||
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage</button>
|
||
<button className="btn" onClick={() => saveForm(false)}>Speichern</button>
|
||
<button className="btn btn-save" onClick={() => saveForm(true)}>Speichern + posten</button>
|
||
{formDraft.id && <button className="btn btn-ghost" onClick={() => setFormDraft(emptyForm)}>Abbrechen</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const tabComposer = (
|
||
<>
|
||
<EmbedComposer channelOptions={channelOptions} flash={flash} />
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Geplante Posts</h2>
|
||
{scheduled.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{scheduled.map((p) => (
|
||
<div className="key-row" key={p.id}>
|
||
<span className="key-name">{channelName(p.channel_id)}</span>
|
||
<span className="key-scopes">
|
||
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
|
||
{p.enabled ? '' : ' · erledigt'}
|
||
</span>
|
||
<span className="key-used" title={p.content}>{p.content.slice(0, 40)}…</span>
|
||
<button className="card-delete" title="Löschen" onClick={() => removeSchedule(p.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Neuer geplanter Post</label>
|
||
<div className="field-row">
|
||
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
|
||
<option value="" disabled>— Kanal wählen —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
|
||
<option value="once">einmalig</option>
|
||
<option value="daily">täglich</option>
|
||
<option value="weekly">wöchentlich</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
{schedDraft.type === 'once' && (
|
||
<input type="date" value={schedDraft.date} onChange={(e) => setSchedDraft({ ...schedDraft, date: e.target.value })} />
|
||
)}
|
||
{schedDraft.type === 'weekly' && (
|
||
<select value={schedDraft.weekday} onChange={(e) => setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
|
||
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
|
||
<option key={i} value={String(i)}>{d}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<input type="time" value={schedDraft.time} onChange={(e) => setSchedDraft({ ...schedDraft, time: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<input type="text" placeholder="Embed-Titel (leer = normale Nachricht)" value={schedDraft.embed_title} onChange={(e) => setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<textarea rows={4} placeholder="Text (Discord-Markdown)" value={schedDraft.content} onChange={(e) => setSchedDraft({ ...schedDraft, content: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<button className="btn btn-save" onClick={createSchedule}>Planen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Triggers</h2>
|
||
<p className="section-intro">
|
||
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
|
||
</p>
|
||
{triggers.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{triggers.map((t) => (
|
||
<div className="key-row" key={t.keyword}>
|
||
<span className="key-name">{t.keyword}</span>
|
||
<span className="key-used" title={t.reply}>{t.reply.slice(0, 50)}</span>
|
||
<button className="btn btn-mini" onClick={() => setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten</button>
|
||
<button className="card-delete" title="Trigger löschen" onClick={() => removeTrigger(t.keyword)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="Keyword (min 3 Zeichen)" value={triggerDraft.keyword} onChange={(e) => setTriggerDraft({ ...triggerDraft, keyword: e.target.value })} />
|
||
<input type="text" placeholder="Antwort des Bots" value={triggerDraft.reply} onChange={(e) => setTriggerDraft({ ...triggerDraft, reply: e.target.value })} />
|
||
<button className="btn" onClick={saveTriggerDraft}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Tags (/tag)</h2>
|
||
<p className="section-intro">
|
||
Text-Bausteine für wiederkehrende Antworten — jeder Member kann sie mit <code>/tag name</code> abrufen.
|
||
</p>
|
||
{tags.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{tags.map((t) => (
|
||
<div className="key-row" key={t.name}>
|
||
<span className="key-name">{t.name}</span>
|
||
<span className="key-used" title={t.content}>{t.content.slice(0, 50)}</span>
|
||
<button className="btn btn-mini" onClick={() => setTagDraft({ name: t.name, content: t.content })}>Bearbeiten</button>
|
||
<button className="card-delete" title="Tag löschen" onClick={() => removeTag(t.name)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="name (z. B. server-ip)" value={tagDraft.name} onChange={(e) => setTagDraft({ ...tagDraft, name: e.target.value })} />
|
||
<input type="text" placeholder="Inhalt des Tags" value={tagDraft.content} onChange={(e) => setTagDraft({ ...tagDraft, content: e.target.value })} />
|
||
<button className="btn" onClick={saveTagDraft}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabSystem = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Allgemein</h2>
|
||
<div className="field">
|
||
<label>Öffentliche URL <span className="field-hint">Basis für Buttons, RSS, OAuth-Redirect</span></label>
|
||
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.public_url} onChange={(e) => setForm({ ...form, public_url: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Gitea-URL</label>
|
||
<input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</span></label>
|
||
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.bug_report_repo} onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Roadmap-Repo <span className="field-hint">Milestones → öffentliche /roadmap</span></label>
|
||
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.roadmap_repo} onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Web-Login (Member)</h2>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.member_gate_enabled} onChange={(e) => setForm({ ...form, member_gate_enabled: e.target.checked })} />
|
||
<span>Login nur für Discord-Member</span>
|
||
<span className="field-hint">Nicht-Member werden nach dem Discord-Login abgewiesen (braucht gesetzte Guild-ID)</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label>Discord-Invite-Link <span className="field-hint">wird Abgewiesenen als „Zum Discord"-Button angezeigt</span></label>
|
||
<input type="text" placeholder="https://discord.gg/…" value={form.discord_invite_url} onChange={(e) => setForm({ ...form, discord_invite_url: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||
<div className="field">
|
||
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
||
<select value={form.events_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, events_announce_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
||
<select value={form.social_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, social_announce_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<input type="text" placeholder="YouTube-Kanal-ID (UC…)" value={form.youtube_channel_id} onChange={(e) => setForm({ ...form, youtube_channel_id: e.target.value })} />
|
||
<input type="text" placeholder="Twitch-Login (z. B. d4rkst3r)" value={form.twitch_channel} onChange={(e) => setForm({ ...form, twitch_channel: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Twitch-App-Credentials <span className="field-hint">{form.twitch_creds_set ? '✓ gesetzt' : 'dev.twitch.tv → App registrieren'} — write-only</span></label>
|
||
<div className="field-row">
|
||
<input type="password" placeholder="Client-ID" value={twitchCreds.id} onChange={(e) => setTwitchCreds({ ...twitchCreds, id: e.target.value })} />
|
||
<input type="password" placeholder="Client-Secret" value={twitchCreds.secret} onChange={(e) => setTwitchCreds({ ...twitchCreds, secret: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Temp-Voice-Hub <span className="field-hint">„Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
|
||
<select value={form.tempvoice_channel_id ?? ''} onChange={(e) => setForm({ ...form, tempvoice_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Backups & Watchdog</h2>
|
||
<div className="field">
|
||
<label className="toggle">
|
||
<input type="checkbox" checked={form.backup_enabled} onChange={(e) => setForm({ ...form, backup_enabled: e.target.checked })} />
|
||
<span>Nächtliches DB-Backup (03:00)</span>
|
||
<span className="field-hint">gzip, 14 Tage Rotation</span>
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
|
||
<div className="field-row">
|
||
<select value={form.backup_channel_id ?? ''} onChange={(e) => setForm({ ...form, backup_channel_id: e.target.value })}>
|
||
<option value="">— nur lokal —</option>
|
||
{channelOptions}
|
||
</select>
|
||
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — leer = aus</span></label>
|
||
<input type="text" placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health" value={form.watchdog_urls} onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabApi = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
||
{keys.length > 0 && (
|
||
<div className="key-list">
|
||
{keys.map((k) => (
|
||
<div className="key-row" key={k.id}>
|
||
<span className="key-name">{k.name}</span>
|
||
<span className="key-scopes">{k.scopes}</span>
|
||
<span className="key-used">
|
||
{k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
|
||
</span>
|
||
<button className="card-delete" title="Key widerrufen" onClick={() => revokeKey(k.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{createdKey && (
|
||
<div className="key-created">
|
||
<p>Key <strong>{createdKey.name}</strong> erstellt — <strong>jetzt kopieren</strong>, er wird nie wieder angezeigt:</p>
|
||
<code>{createdKey.key}</code>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Neuer Key <span className="field-hint">Name des Skripts/Dienstes + Scopes</span></label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="z. B. devlog.py, platform, fivem" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} />
|
||
<button className="btn" onClick={createKey}>Erstellen</button>
|
||
</div>
|
||
<div className="scope-row">
|
||
{API_SCOPES.map((s) => (
|
||
<label className="scope-pick" key={s.id} title={s.hint}>
|
||
<input
|
||
type="checkbox"
|
||
checked={newKeyScopes.includes(s.id)}
|
||
onChange={(e) =>
|
||
setNewKeyScopes(
|
||
e.target.checked
|
||
? [...newKeyScopes, s.id]
|
||
: newKeyScopes.filter((x) => x !== s.id)
|
||
)
|
||
}
|
||
/>
|
||
{s.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const tabServer = (
|
||
<>
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Game-Server</h2>
|
||
<p className="section-intro">
|
||
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
|
||
Spieler-Balken, Map, Adresse) — aktualisiert alle 2 Minuten, DiscordGSM-Style.
|
||
</p>
|
||
<div className="field">
|
||
<div className="field-row">
|
||
<div style={{ flex: 1 }}>
|
||
<label>Status-Kanal</label>
|
||
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
|
||
<option value="">— deaktiviert —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label>Alert-Kanal <span className="field-hint">🚨 down / ✅ online</span></label>
|
||
<select value={form.server_alert_channel_id ?? ''} onChange={(e) => setForm({ ...form, server_alert_channel_id: e.target.value })}>
|
||
<option value="">— keine Alerts —</option>
|
||
{channelOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
|
||
<button className="btn btn-save" onClick={save}>Kanäle speichern</button>
|
||
</div>
|
||
|
||
{servers.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{servers.map((s) => (
|
||
<div className="key-row" key={s.id}>
|
||
<span className="key-name">{s.name}</span>
|
||
<span className="key-scopes">{s.type}</span>
|
||
<span className="key-used" title={s.query_url}>{s.query_url}</span>
|
||
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
|
||
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
|
||
})}>Bearbeiten</button>
|
||
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
|
||
<div className="field-row">
|
||
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
|
||
<GameSelect
|
||
presets={GAME_PRESETS}
|
||
value={serverDraft.type}
|
||
onChange={(type) => setServerDraft({ ...serverDraft, type })}
|
||
/>
|
||
{serverNeedsUrl ? (
|
||
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
|
||
) : (
|
||
<>
|
||
<input type="text" placeholder="Host / IP" value={serverDraft.host} onChange={(e) => setServerDraft({ ...serverDraft, host: e.target.value })} />
|
||
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder="Port" value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
|
||
</>
|
||
)}
|
||
</div>
|
||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||
Neben FiveM/HTTP funktioniert jede <a href="https://github.com/gamedig/node-gamedig#games-list" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Spiel-ID</a> (300+ Spiele) — Host + Query-Port eintragen.
|
||
</p>
|
||
</div>
|
||
<div className="field">
|
||
<label>Connect-Link + Anzeige-Adresse <span className="field-hint">Link ist im Embed klickbar (auch steam:// / fivem://); Adresse als Copy-Paste-Block</span></label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
|
||
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
|
||
</div>
|
||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const tabTeam = (
|
||
<div className="settings-section">
|
||
<h2 className="settings-title">// Team</h2>
|
||
<p className="section-intro">
|
||
Gib Team-Mitgliedern gezielten Zugriff aufs Webinterface — sie loggen sich ganz
|
||
normal mit Discord ein und sehen nur ihre Bereiche. Brand, System, API-Keys
|
||
und diese Team-Verwaltung bleiben immer dir vorbehalten.
|
||
</p>
|
||
|
||
{team.length > 0 && (
|
||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||
{team.map((t) => (
|
||
<div className="key-row" key={t.user_id}>
|
||
<span className="key-name">{t.username ?? t.user_id}</span>
|
||
<span className="key-scopes">{t.scopes}</span>
|
||
<button className="btn btn-mini" onClick={() => setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten</button>
|
||
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label>Discord-User-ID <span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span></label>
|
||
<div className="field-row">
|
||
<input type="text" placeholder="z. B. 123456789012345678" value={teamDraft.user_id} onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })} />
|
||
<button className="btn btn-save" onClick={saveTeam}>Speichern</button>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Bereiche</label>
|
||
<div className="scope-row" style={{ flexDirection: 'column', gap: '.5rem', alignItems: 'flex-start' }}>
|
||
{Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => (
|
||
<label className="scope-pick" key={id}>
|
||
<input
|
||
type="checkbox"
|
||
checked={teamDraft.scopes.includes(id)}
|
||
onChange={(e) =>
|
||
setTeamDraft({
|
||
...teamDraft,
|
||
scopes: e.target.checked
|
||
? [...teamDraft.scopes, id]
|
||
: teamDraft.scopes.filter((s) => s !== id),
|
||
})
|
||
}
|
||
/>
|
||
{id} — {label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const content = {
|
||
status: tabStatus,
|
||
brand: tabBrand,
|
||
feeds: tabFeeds,
|
||
community: tabCommunity,
|
||
rollen: tabRollen,
|
||
support: tabSupport,
|
||
bewerbungen: tabBewerbungen,
|
||
composer: tabComposer,
|
||
server: tabServer,
|
||
system: tabSystem,
|
||
api: tabApi,
|
||
team: tabTeam,
|
||
}[tab];
|
||
|
||
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
||
const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
|
||
|
||
return (
|
||
<>
|
||
{header}
|
||
<section className="content content-wide">
|
||
<div className="settings-layout">
|
||
<nav className="settings-nav">
|
||
{visibleTabs.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
||
onClick={() => setTab(t.id)}
|
||
>
|
||
<span className="settings-nav-icon">{t.icon}</span>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="settings-content">
|
||
{content}
|
||
{showSave && (
|
||
<div className="settings-actions">
|
||
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{feedback && <div className="feedback-float">{feedback}</div>}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|