Setup-Seite: Kanal-Auswahl, Feed-Regeln und Status im Webinterface

- Settings-Tabelle in SQLite; Env-Variablen nur noch Fallback, Änderungen greifen sofort
- /settings (Admin): Devlog-/Commit-Kanal als Dropdown aus allen sichtbaren Textkanälen,
  je mit Test-senden-Button
- Commit-Feed-Regeln: an/aus, Branch-Filter, ignorierte Repos (archiviert wird immer,
  gefiltert wird nur das Posten; ignorierte Repos komplett übersprungen)
- Status-Panel: Bot-Tag, Uptime, Devlog-/Commit-Zahlen, DB-Größe
- API: GET/PUT /api/settings, POST /api/settings/test/:target (alles Admin-only)
- COMMIT_CHANNEL_ID/DEVLOG_CHANNEL_ID in config optional

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:21:05 +02:00
co-authored by Claude Opus 4.8
parent 71d2bb07f1
commit ae5732a61b
14 changed files with 516 additions and 23 deletions
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost } from '../api.js';
/** 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);
useEffect(() => {
if (!me.admin) return;
apiGet('/api/settings')
.then((d) => {
setData(d);
setForm(d.settings);
})
.catch((e) => setError(e.status));
}, [me.admin]);
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 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>
</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>
{/* 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>
</>
);
}