- 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>
635 lines
32 KiB
React
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 <Datum>"</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 & 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>
|
|
</>
|
|
);
|
|
}
|