Files
d4rkbot/frontend/src/pages/Settings.jsx
T
D4rkst3randClaude Opus 4.8 4d1b0a956d Ticket-System, Web-Composer und Nachrichten-Bearbeitung
- Tickets: /ticket-setup postet 🎫-Button; Klick erstellt privaten Thread
  (ein offenes Ticket pro User), Schließen-Button sperrt + archiviert
- Composer auf der Setup-Seite: Nachricht/Embed als Bot in beliebigen Kanal
  senden oder per Message-ID bearbeiten (nur eigene Bot-Posts)
- API v1: PATCH /api/v1/message — Skripte können ihre Bot-Posts aktualisieren
- Setting ticket_channel_id; README ergänzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:43:27 +02:00

635 lines
32 KiB
React

import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
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' },
];
/** Sekunden → "2d 4h 13m" */
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 [data, setData] = useState(null);
const [form, setForm] = useState(null);
const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null);
// Community
const [playtesters, setPlaytesters] = useState([]);
// Composer
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
// API-Keys
const [keys, setKeys] = useState([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState(['message']);
const [createdKey, setCreatedKey] = useState(null);
useEffect(() => {
if (!me.admin) 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(() => {});
}, [me.admin]);
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);
}
function flash(msg) {
setFeedback(msg);
setTimeout(() => setFeedback(null), 3000);
}
async function save() {
try {
const res = await apiPut('/api/settings', form);
setForm(res.settings);
flash('✓ Gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
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');
}
}
async function sendTest(target) {
try {
await apiPost(`/api/settings/test/${target}`);
flash('✓ Test-Nachricht gesendet');
} catch {
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
}
}
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">Kanäle, Feed-Regeln und Status Ä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) {
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>
));
return (
<>
{header}
<section className="content">
{/* Status */}
<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>
{/* Allgemein */}
<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 und OAuth-Redirect (Redirect auch im Dev-Portal eintragen)</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 <span className="field-hint">Basis für die Issue-API (/bug)</span></label>
<input
type="text"
placeholder="https://git.d4rkst3r.de"
value={form.gitea_url}
onChange={(e) => setForm({ ...form, gitea_url: e.target.value })}
/>
</div>
</div>
{/* Kanäle */}
<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 senden</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 senden</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 senden</button>
</div>
</div>
</div>
{/* Devlog */}
<div className="settings-section">
<h2 className="settings-title">// Devlog</h2>
<div className="field">
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog-Post erwähnt; Member abonnieren sie über den 🔔-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>
{(data.roles ?? []).map((r) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</option>
))}
</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 in den Devlog-Kanal</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">Bot öffnet automatisch „💬 Devlog &lt;Datum&gt;"</span>
</label>
</div>
<div className="field">
<button className="btn" onClick={() => sendTest('recap')}>Rückblick jetzt testen</button>
</div>
</div>
{/* Bug-Reports */}
<div className="settings-section">
<h2 className="settings-title">// Bug-Reports (/bug)</h2>
{!data.status.giteaTokenConfigured && (
<p className="notice" style={{ padding: '0 0 .8rem' }}>
⚠️ Kein <code>GITEA_API_TOKEN</code> gesetzt — /bug ist deaktiviert.
</p>
)}
<div className="field">
<label>Ziel-Repo <span className="field-hint">hier landen die Issues aus /bug</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 dieses Repos speisen die öffentliche /roadmap-Seite</span></label>
<input
type="text"
placeholder="D4rkst3r/EcoGame"
value={form.roadmap_repo}
onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })}
/>
</div>
</div>
{/* Watchdog */}
<div className="settings-section">
<h2 className="settings-title">// Watchdog</h2>
<div className="field">
<label>Überwachte URLs <span className="field-hint">kommagetrennt; bei Ausfall gibt's eine DM — 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>
{/* Community */}
<div className="settings-section">
<h2 className="settings-title">// Community</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>
{(data.roles ?? []).map((r) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</option>
))}
</select>
{playtesters.length > 0 && (
<div className="key-list" style={{ marginTop: '.8rem' }}>
{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="field">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug landen hier; leer = aus</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: '7rem', 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 landen in der öffentlichen 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 der Roadmap-Seite</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>
{/* Moderation & Kontakt */}
<div className="settings-section">
<h2 className="settings-title">// Moderation &amp; 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>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member (braucht Server-Members-Intent im Dev-Portal!)</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 className="field">
<label>Ticket-Kanal <span className="field-hint">hier entstehen die privaten 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>
{/* Composer */}
<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={6}
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 statt neu senden</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" onClick={sendCompose}>
{composer.messageId.trim() ? 'Aktualisieren' : 'Senden'}
</button>
</div>
</div>
</div>
{/* Game-Server */}
<div className="settings-section">
<h2 className="settings-title">// Game-Server</h2>
<div className="field">
<label>Status-Kanal <span className="field-hint">persistentes Status-Embed, alle 2 min aktualisiert</span></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 className="field">
<label>Server <span className="field-hint">kommagetrennt Name=URL FiveM-kompatibel (/dynamic.json)</span></label>
<input
type="text"
placeholder="z. B. FiveM=http://1.2.3.4:30120, EcoGame=http://…"
value={form.gameservers}
onChange={(e) => setForm({ ...form, gameservers: e.target.value })}
/>
</div>
</div>
{/* Backups */}
<div className="settings-section">
<h2 className="settings-title">// Backups</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 nach data/backups/, 14 Tage Rotation</span>
</label>
</div>
<div className="field">
<label>Upload-Kanal <span className="field-hint">Offsite-Kopie in einen 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>
{/* API-Keys */}
<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>
{/* Commit-Feed */}
<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 = Commits werden weiter archiviert, nur nicht gepostet</span>
</label>
</div>
<div className="field">
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle Branches posten</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 werden komplett übersprungen</span></label>
<input
type="text"
placeholder="z. B. D4rkst3r/ecobot"
value={form.ignored_repos}
onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })}
/>
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={save}>Speichern</button>
{feedback && <span className="feedback">{feedback}</span>}
</div>
</section>
</>
);
}