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:
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||
import { apiGet } from './api.js';
|
||||
import Devlogs from './pages/Devlogs.jsx';
|
||||
import Commits from './pages/Commits.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
|
||||
function DiscordMark() {
|
||||
return (
|
||||
@@ -69,6 +70,7 @@ export default function App() {
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/devlogs">Devlog</NavLink>
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||
</nav>
|
||||
|
||||
<div className="auth">
|
||||
@@ -91,6 +93,7 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
<Route path="/commits" element={<Commits me={me} />} />
|
||||
<Route path="/settings" element={<Settings me={me} />} />
|
||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -11,3 +11,15 @@ async function api(path, options = {}) {
|
||||
|
||||
export const apiGet = (path) => api(path);
|
||||
export const apiDelete = (path) => api(path, { method: 'DELETE' });
|
||||
export const apiPut = (path, body) =>
|
||||
api(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
export const apiPost = (path, body = {}) =>
|
||||
api(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -354,6 +354,86 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
font-size: .68rem; color: var(--muted2); white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── SETTINGS ──────────────────────────────────────── */
|
||||
.settings-section {
|
||||
background: var(--bg2);
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
padding: 1.4rem 1.6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-title {
|
||||
font-family: var(--mono);
|
||||
font-size: .72rem; letter-spacing: .3em; text-transform: uppercase;
|
||||
color: var(--neon2);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.stats { display: flex; flex-wrap: wrap; gap: 2.5rem; }
|
||||
.stat { display: flex; flex-direction: column; gap: .1rem; }
|
||||
.stat-value {
|
||||
font-family: var(--display);
|
||||
font-size: 1.5rem; letter-spacing: .05em; color: var(--text);
|
||||
}
|
||||
.stat-label {
|
||||
font-family: var(--mono);
|
||||
font-size: .6rem; letter-spacing: .25em; text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.field { margin-bottom: 1.1rem; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field > label {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: .68rem; letter-spacing: .15em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
.field-hint {
|
||||
text-transform: none; letter-spacing: .03em;
|
||||
color: var(--muted2); margin-left: .5rem;
|
||||
}
|
||||
.field-row { display: flex; gap: .6rem; }
|
||||
.field-row select { flex: 1; }
|
||||
|
||||
.field select, .field input[type="text"] {
|
||||
width: 100%;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
color: var(--text);
|
||||
font-family: var(--body);
|
||||
font-size: .95rem;
|
||||
padding: .5rem .7rem;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); }
|
||||
.field select option { background: #0d0d0d; }
|
||||
|
||||
.toggle { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
width: 34px; height: 18px; flex-shrink: 0;
|
||||
background: #222; border: 1px solid rgba(255, 255, 255, .15);
|
||||
position: relative; cursor: pointer;
|
||||
transition: background .2s, border-color .2s;
|
||||
}
|
||||
.toggle input::after {
|
||||
content: '';
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--muted);
|
||||
transition: transform .2s, background .2s;
|
||||
}
|
||||
.toggle input:checked { background: rgba(245, 197, 24, .2); border-color: var(--neon); }
|
||||
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); }
|
||||
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; }
|
||||
|
||||
.settings-actions { display: flex; align-items: center; gap: 1rem; }
|
||||
.btn-save { background: var(--neon); color: #0a0a0a; border-color: var(--neon); font-weight: 600; }
|
||||
.btn-save:hover:not(:disabled) { background: var(--text); border-color: var(--text); }
|
||||
.feedback { font-family: var(--mono); font-size: .75rem; letter-spacing: .1em; color: var(--success); }
|
||||
|
||||
/* ── PAGINATION ────────────────────────────────────── */
|
||||
.pager {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user