Server-Monitor v3, Composer v2, Temp-Voice-Panel und Embed-Polish

- Server-Monitor v3: gamedig-Integration (300+ Spiele), GSM-Stil-Embeds
  (Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link,
  Ping im Footer), Down/Up-Alerts (2 Fails → 🚨, Recovery →  mit
  Downtime) in konfigurierbaren Alert-Kanal; Server-Tab mit 19
  Spiel-Presets, Host/Port bzw. Query-URL und Connect-URL
- Composer v2: visueller Embed-Builder mit Live-Discord-Preview
  (Autor, Felder, Bilder, Farbe, Footer, Timestamp), speicherbare
  Vorlagen (/api/templates), /api/compose versteht volle Embeds
- Temp-Voice: Control-Panel im erstellten Kanal (Umbenennen, Sperren,
  Limit, Löschen) — nur der Kanal-Besitzer darf bedienen
- Embed-Polish: brandEmbed-Factory (src/embeds.js), Footer überall
  mit Bot-Avatar-Icon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:17:30 +02:00
co-authored by Claude Fable 5
parent 332e89dcbd
commit 00249f9d8b
17 changed files with 1379 additions and 116 deletions
+59 -64
View File
@@ -1,5 +1,6 @@
import { useEffect, 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' },
@@ -77,8 +78,6 @@ export default function Settings({ me }) {
};
const [menus, setMenus] = useState([]);
const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Composer
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
// Branding
const [giteaToken, setGiteaToken] = useState('');
const [botName, setBotName] = useState('');
@@ -97,7 +96,17 @@ export default function Settings({ me }) {
const [triggers, setTriggers] = useState([]);
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
// Game-Server
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '' };
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', '🔧 Garrys 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
@@ -177,9 +186,10 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins);
}
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
async function saveServer() {
if (!serverDraft.name.trim() || !serverDraft.query_url.trim()) {
flash('✗ Name und Query-URL nötig');
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 {
@@ -388,25 +398,6 @@ export default function Settings({ me }) {
if (createdKey?.id === id) setCreatedKey(null);
}
async function sendCompose() {
if (!composer.channel || !composer.text.trim()) {
flash('✗ Kanal und Text nötig');
return;
}
try {
const res = await apiPost('/api/compose', {
channel_id: composer.channel,
message_id: composer.messageId.trim() || undefined,
embed_title: composer.title.trim() || undefined,
content: composer.text,
});
flash(res.edited ? '✓ Aktualisiert' : `✓ Gesendet (ID ${res.message_id})`);
if (!res.edited) setComposer({ ...composer, messageId: res.message_id });
} catch {
flash('✗ Senden fehlgeschlagen');
}
}
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
async function saveMenu(publish = false) {
@@ -1024,33 +1015,7 @@ export default function Settings({ me }) {
const tabComposer = (
<>
<div className="settings-section">
<h2 className="settings-title">// Post verfassen</h2>
<div className="field">
<label>Kanal</label>
<select value={composer.channel} onChange={(e) => setComposer({ ...composer, channel: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Embed-Titel <span className="field-hint">leer = normale Nachricht statt Embed</span></label>
<input type="text" placeholder="z. B. 📣 Ankündigung" value={composer.title} onChange={(e) => setComposer({ ...composer, title: e.target.value })} />
</div>
<div className="field">
<label>Text <span className="field-hint">Discord-Markdown (##, **, Listen )</span></label>
<textarea rows={8} value={composer.text} onChange={(e) => setComposer({ ...composer, text: e.target.value })} />
</div>
<div className="field">
<label>Message-ID <span className="field-hint">optional vorhandenen Bot-Post bearbeiten</span></label>
<div className="field-row">
<input type="text" placeholder="leer = neue Nachricht" value={composer.messageId} onChange={(e) => setComposer({ ...composer, messageId: e.target.value })} />
<button className="btn btn-save" onClick={sendCompose}>
{composer.messageId.trim() ? 'Aktualisieren' : 'Senden'}
</button>
</div>
</div>
</div>
<EmbedComposer channelOptions={channelOptions} flash={flash} />
<div className="settings-section">
<h2 className="settings-title">// Geplante Posts</h2>
@@ -1311,14 +1276,25 @@ export default function Settings({ me }) {
Spieler-Balken, Map, Adresse) aktualisiert alle 2 Minuten, DiscordGSM-Style.
</p>
<div className="field">
<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 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}>Kanal speichern</button>
<button className="btn btn-save" onClick={save}>Kanäle speichern</button>
</div>
{servers.length > 0 && (
@@ -1329,7 +1305,8 @@ export default function Settings({ me }) {
<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 ?? '',
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>
@@ -1341,17 +1318,35 @@ export default function Settings({ me }) {
<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 })} />
<select style={{ flex: 'none', width: '8rem' }} value={serverDraft.type} onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}>
<option value="fivem">FiveM</option>
<option value="http">HTTP-Check</option>
</select>
<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" list="game-presets" style={{ flex: 'none', width: '13rem' }}
placeholder="Spiel (gamedig-ID)" title="Vorschläge oder beliebige gamedig-Spiel-ID"
value={serverDraft.type}
onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}
/>
<datalist id="game-presets">
{GAME_PRESETS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</datalist>
{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>Anzeige-Adresse <span className="field-hint">optional steht als Copy-Paste-Block im Embed (z. B. connect play.d4rkst3r.de)</span></label>
<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>