Rollen-Menüs (Carl-Bot-Ersatz) + Setup-Redesign mit Tab-Sidebar
- Rollen-Menüs: Builder im Webinterface (Titel, Beschreibung, Kanal, Emoji/Label/Rolle, Exklusiv-Modus = max. 1 Rolle pro Menü), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben (ephemeres Feedback), Update editiert den Discord-Post in place, Löschen räumt ihn mit auf - Bewusst Buttons statt klassischer Emoji-Reaktionen: kein Reaction-Spam am Post, kein Custom-Emoji-Parsing, exklusive Menüs möglich; halbfertige Reaction-Variante aus abgebrochener Session entfernt und konsolidiert - Setup-Seite: komplett neu strukturiert in 8 Tabs mit sticky Sidebar (Status/Feeds/Community/Rollen/Support/Composer/System/API), Feedback als Toast - Modern-Polish: weiche Radien auf Karten/Inputs/Buttons, ruhigere Input-Flächen mit Fokus-Glow, Sektions-Hover; JSON-Parser akzeptiert leere Bodies - API: /api/rolemenus CRUD + publish (Admin), README aktualisiert Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
||||
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
|
||||
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
||||
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) |
|
||||
| 🎭 **Rollen-Menüs** | Carl-Bot-Ersatz: Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben, Posts jederzeit editierbar |
|
||||
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
||||
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
|
||||
@@ -51,8 +52,9 @@ Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Conden
|
||||
Share Tech Mono, selbst gehostet).
|
||||
|
||||
### Setup-Seite (`/settings`)
|
||||
Alles zur Laufzeit änderbar — gespeichert in SQLite, Env-Variablen sind nur Fallback,
|
||||
kein Redeploy nötig:
|
||||
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Feeds · Community · Rollen ·
|
||||
Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
|
||||
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
|
||||
|
||||
- **Allgemein:** Öffentliche URL, Gitea-URL
|
||||
- **Kanäle** (Devlog / Commit / Release) als Dropdown + „Test senden"-Button je Kanal
|
||||
@@ -237,6 +239,7 @@ urllib.request.urlopen(req)
|
||||
| `GET /api/playtesters` | Admin | Playtester-Liste |
|
||||
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
|
||||
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
|
||||
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
|
||||
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
|
||||
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
|
||||
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
|
||||
|
||||
+327
-272
@@ -8,6 +8,17 @@ const API_SCOPES = [
|
||||
{ id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' },
|
||||
];
|
||||
|
||||
const TABS = [
|
||||
{ id: 'status', icon: '📊', label: 'Status' },
|
||||
{ id: 'feeds', icon: '📔', label: 'Feeds' },
|
||||
{ id: 'community', icon: '🙌', label: 'Community' },
|
||||
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
||||
{ id: 'support', icon: '📬', label: 'Support' },
|
||||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
||||
{ id: 'system', icon: '⚙️', label: 'System' },
|
||||
{ id: 'api', icon: '🔑', label: 'API' },
|
||||
];
|
||||
|
||||
/** Sekunden → "2d 4h 13m" */
|
||||
function fmtUptime(s) {
|
||||
const d = Math.floor(s / 86400);
|
||||
@@ -22,12 +33,20 @@ function fmtBytes(b) {
|
||||
}
|
||||
|
||||
export default function Settings({ me }) {
|
||||
const [tab, setTab] = useState('status');
|
||||
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([]);
|
||||
// Rollen-Menüs
|
||||
const emptyMenu = {
|
||||
id: null, title: '', description: '', channel_id: '', exclusive: false,
|
||||
entries: [{ emoji: '', label: '', role_id: '' }],
|
||||
};
|
||||
const [menus, setMenus] = useState([]);
|
||||
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||||
// Composer
|
||||
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
||||
// API-Keys
|
||||
@@ -46,8 +65,33 @@ export default function Settings({ me }) {
|
||||
.catch((e) => setError(e.status));
|
||||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||||
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||||
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||||
}, [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');
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!newKeyName.trim() || newKeyScopes.length === 0) return;
|
||||
try {
|
||||
@@ -67,21 +111,6 @@ export default function Settings({ me }) {
|
||||
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');
|
||||
@@ -101,15 +130,57 @@ export default function Settings({ me }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest(target) {
|
||||
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
|
||||
|
||||
async function saveMenu(publish = false) {
|
||||
const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id);
|
||||
if (!menuDraft.title.trim() || entries.length === 0) {
|
||||
flash('✗ Titel und mindestens eine Rolle nötig');
|
||||
return;
|
||||
}
|
||||
if (publish && !menuDraft.channel_id) {
|
||||
flash('✗ Zum Posten einen Kanal wählen');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPost(`/api/settings/test/${target}`);
|
||||
flash('✓ Test-Nachricht gesendet');
|
||||
const body = { ...menuDraft, entries };
|
||||
const res = menuDraft.id
|
||||
? await apiPut(`/api/rolemenus/${menuDraft.id}`, body)
|
||||
: await apiPost('/api/rolemenus', body);
|
||||
let menu = res.menu;
|
||||
if (publish) {
|
||||
await apiPost(`/api/rolemenus/${menu.id}/publish`);
|
||||
flash('✓ Menü in Discord gepostet');
|
||||
} else {
|
||||
flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert');
|
||||
}
|
||||
await reloadMenus();
|
||||
setMenuDraft(emptyMenu);
|
||||
} catch {
|
||||
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
|
||||
flash('✗ Speichern fehlgeschlagen — Einträge prüfen');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMenu(id) {
|
||||
if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return;
|
||||
await apiDelete(`/api/rolemenus/${id}`).catch(() => {});
|
||||
if (menuDraft.id === id) setMenuDraft(emptyMenu);
|
||||
await reloadMenus();
|
||||
}
|
||||
|
||||
const editMenu = (m) =>
|
||||
setMenuDraft({
|
||||
id: m.id, title: m.title, description: m.description,
|
||||
channel_id: m.channel_id ?? '', exclusive: m.exclusive,
|
||||
entries: m.entries.length ? m.entries : [{ emoji: '', label: '', role_id: '' }],
|
||||
});
|
||||
|
||||
const setMenuEntry = (i, patch) =>
|
||||
setMenuDraft({
|
||||
...menuDraft,
|
||||
entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)),
|
||||
});
|
||||
|
||||
const header = (
|
||||
<section className="page-header">
|
||||
<div className="page-bg-text">SETUP</div>
|
||||
@@ -117,7 +188,7 @@ export default function Settings({ me }) {
|
||||
<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>
|
||||
<p className="page-subtitle">Alles an einem Ort — Änderungen greifen sofort.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -140,16 +211,19 @@ export default function Settings({ me }) {
|
||||
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>
|
||||
<option key={c.id} value={c.id}>#{c.name} — {c.guild}</option>
|
||||
));
|
||||
const roleOptions = (data.roles ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id}>@{r.name} — {r.guild}</option>
|
||||
));
|
||||
const channelName = (id) => {
|
||||
const c = data.channels.find((x) => x.id === id);
|
||||
return c ? `#${c.name}` : id;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<section className="content">
|
||||
{/* Status */}
|
||||
/* ── Tab-Inhalte ───────────────────────────────── */
|
||||
|
||||
const tabStatus = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Status</h2>
|
||||
<div className="stats">
|
||||
@@ -164,112 +238,65 @@ export default function Settings({ me }) {
|
||||
</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 */}
|
||||
const tabFeeds = (
|
||||
<>
|
||||
<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 })}
|
||||
>
|
||||
<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>
|
||||
<button className="btn" onClick={() => sendTest('devlog')}>Test</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 })}
|
||||
>
|
||||
<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>
|
||||
<button className="btn" onClick={() => sendTest('commit')}>Test</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 })}
|
||||
>
|
||||
<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>
|
||||
<button className="btn" onClick={() => sendTest('release')}>Test</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 })}
|
||||
>
|
||||
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-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>
|
||||
))}
|
||||
{roleOptions}
|
||||
</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 })}
|
||||
/>
|
||||
<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>
|
||||
<span className="field-hint">Commits pro Tag, Devlogs & Top-Projekte</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 })}
|
||||
/>
|
||||
<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>
|
||||
<span className="field-hint">„💬 Devlog <Datum>"</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
@@ -277,65 +304,40 @@ export default function Settings({ me }) {
|
||||
</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>
|
||||
)}
|
||||
<h2 className="settings-title">// Commit-Feed</h2>
|
||||
<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 })}
|
||||
/>
|
||||
<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 = weiter archivieren, nur nicht posten</span>
|
||||
</label>
|
||||
</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 })}
|
||||
/>
|
||||
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle</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>
|
||||
|
||||
{/* 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 })}
|
||||
/>
|
||||
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen</span></label>
|
||||
<input type="text" placeholder="z. B. D4rkst3r/d4rkbot" value={form.ignored_repos} onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* Community */}
|
||||
const tabCommunity = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Community</h2>
|
||||
|
||||
<h2 className="settings-title">// Playtester</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 })}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{roleOptions}
|
||||
</select>
|
||||
</div>
|
||||
{playtesters.length > 0 && (
|
||||
<div className="key-list" style={{ marginTop: '.8rem' }}>
|
||||
<div className="key-list">
|
||||
{playtesters.map((p) => (
|
||||
<div className="key-row" key={p.user_id}>
|
||||
<span className="key-name">{p.username}</span>
|
||||
@@ -346,94 +348,155 @@ export default function Settings({ me }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Starboard & Galerie</h2>
|
||||
<div className="field">
|
||||
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier; leer = aus</span></label>
|
||||
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
|
||||
<div className="field-row">
|
||||
<select
|
||||
value={form.starboard_channel_id ?? ''}
|
||||
onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}
|
||||
>
|
||||
<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 })}
|
||||
/>
|
||||
<input type="text" style={{ width: '6rem', 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 })}
|
||||
>
|
||||
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche 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 })}
|
||||
>
|
||||
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</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 */}
|
||||
const tabRollen = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||||
<p className="section-intro">
|
||||
Selbstbedienungs-Rollen wie bei Carl-bot — nur ohne Paywall. 😄 Der Bot postet
|
||||
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
|
||||
jederzeit editierbar, der Discord-Post zieht automatisch mit.
|
||||
</p>
|
||||
|
||||
{menus.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{menus.map((m) => (
|
||||
<div className="key-row" key={m.id}>
|
||||
<span className="key-name">{m.title}</span>
|
||||
<span className="key-scopes">
|
||||
{m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
|
||||
{m.exclusive ? ' · exklusiv' : ''}
|
||||
</span>
|
||||
<span className="key-used">
|
||||
{m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
|
||||
</span>
|
||||
<button className="btn btn-mini" onClick={() => editMenu(m)}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Menü + Discord-Post löschen" onClick={() => removeMenu(m.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="Titel, z. B. 🎭 Hol dir deine Rollen" value={menuDraft.title} onChange={(e) => setMenuDraft({ ...menuDraft, title: e.target.value })} />
|
||||
<select value={menuDraft.channel_id} onChange={(e) => setMenuDraft({ ...menuDraft, channel_id: e.target.value })}>
|
||||
<option value="">— Kanal wählen —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Beschreibung <span className="field-hint">optional, steht im Embed über den Buttons</span></label>
|
||||
<input type="text" placeholder="z. B. Wähl aus, wofür du Pings bekommen willst" value={menuDraft.description} onChange={(e) => setMenuDraft({ ...menuDraft, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={menuDraft.exclusive} onChange={(e) => setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
|
||||
<span>Exklusiv</span>
|
||||
<span className="field-hint">max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Rollen <span className="field-hint">Emoji optional (auch Custom als <:name:id>), Label = Button-Text</span></label>
|
||||
{menuDraft.entries.map((entry, i) => (
|
||||
<div className="rr-row" key={i}>
|
||||
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
|
||||
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
|
||||
<select value={entry.role_id} onChange={(e) => setMenuEntry(i, { role_id: e.target.value })}>
|
||||
<option value="" disabled>— Rolle —</option>
|
||||
{roleOptions}
|
||||
</select>
|
||||
<button
|
||||
className="card-delete"
|
||||
title="Zeile entfernen"
|
||||
onClick={() => setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
|
||||
disabled={menuDraft.entries.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||||
<button className="btn" onClick={() => setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
|
||||
+ Rolle
|
||||
</button>
|
||||
<button className="btn" onClick={() => saveMenu(false)}>Speichern</button>
|
||||
<button className="btn btn-save" onClick={() => saveMenu(true)}>Speichern + posten</button>
|
||||
{menuDraft.id && (
|
||||
<button className="btn btn-ghost" onClick={() => setMenuDraft(emptyMenu)}>Abbrechen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabSupport = (
|
||||
<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 })}
|
||||
>
|
||||
<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 })}
|
||||
>
|
||||
<label>Ticket-Kanal <span className="field-hint">private 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 className="field">
|
||||
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</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 })}
|
||||
>
|
||||
<select value={form.modlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, modlog_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Composer */}
|
||||
const tabComposer = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Post verfassen</h2>
|
||||
<div className="field">
|
||||
@@ -445,94 +508,91 @@ export default function Settings({ me }) {
|
||||
</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 })}
|
||||
/>
|
||||
<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 })}
|
||||
/>
|
||||
<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 statt neu senden</span></label>
|
||||
<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" onClick={sendCompose}>
|
||||
<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>
|
||||
);
|
||||
|
||||
const tabSystem = (
|
||||
<>
|
||||
<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, OAuth-Redirect</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</label>
|
||||
<input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</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 → öffentliche /roadmap</span></label>
|
||||
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.roadmap_repo} onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })} />
|
||||
</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 })}
|
||||
>
|
||||
<label>Status-Kanal <span className="field-hint">persistentes Status-Embed, alle 2 min</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 })}
|
||||
/>
|
||||
<input type="text" placeholder="z. B. FiveM=http://1.2.3.4:30120" value={form.gameservers} onChange={(e) => setForm({ ...form, gameservers: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backups */}
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Backups</h2>
|
||||
<h2 className="settings-title">// Backups & Watchdog</h2>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.backup_enabled}
|
||||
onChange={(e) => setForm({ ...form, backup_enabled: e.target.checked })}
|
||||
/>
|
||||
<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>
|
||||
<span className="field-hint">gzip, 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>
|
||||
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in 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 })}
|
||||
>
|
||||
<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 className="field">
|
||||
<label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — 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>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* API-Keys */}
|
||||
const tabApi = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className="key-list">
|
||||
{keys.map((k) => (
|
||||
@@ -547,23 +607,16 @@ export default function Settings({ me }) {
|
||||
))}
|
||||
</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)}
|
||||
/>
|
||||
<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">
|
||||
@@ -586,48 +639,50 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Commit-Feed */}
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Commit-Feed</h2>
|
||||
const content = {
|
||||
status: tabStatus,
|
||||
feeds: tabFeeds,
|
||||
community: tabCommunity,
|
||||
rollen: tabRollen,
|
||||
support: tabSupport,
|
||||
composer: tabComposer,
|
||||
system: tabSystem,
|
||||
api: tabApi,
|
||||
}[tab];
|
||||
|
||||
<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>
|
||||
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
||||
const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
|
||||
|
||||
<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>
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<section className="content content-wide">
|
||||
<div className="settings-layout">
|
||||
<nav className="settings-nav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
<span className="settings-nav-icon">{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-content">
|
||||
{content}
|
||||
{showSave && (
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||||
{feedback && <span className="feedback">{feedback}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{feedback && <div className="feedback-float">{feedback}</div>}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -544,6 +544,115 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
font-size: .68rem; color: var(--muted2); white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── SETTINGS-LAYOUT (Sidebar + Content) ───────────── */
|
||||
.content-wide { max-width: 1060px; }
|
||||
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
min-width: 11rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-nav-item {
|
||||
display: flex; align-items: center; gap: .7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: .72rem; letter-spacing: .18em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
padding: .55rem .9rem;
|
||||
text-align: left;
|
||||
transition: color .15s, background .15s, border-color .15s;
|
||||
}
|
||||
.settings-nav-item:hover { color: var(--text); background: rgba(255, 255, 255, .03); }
|
||||
.settings-nav-item.active {
|
||||
color: var(--neon);
|
||||
background: rgba(245, 197, 24, .06);
|
||||
border-left-color: var(--neon);
|
||||
}
|
||||
.settings-nav-icon { font-size: .95rem; }
|
||||
|
||||
.settings-content { flex: 1; min-width: 0; animation: riseIn .3s ease both; }
|
||||
|
||||
.section-intro {
|
||||
color: var(--muted);
|
||||
font-weight: 300;
|
||||
margin-bottom: 1.2rem;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
/* Reaction-Roles-Editor */
|
||||
.rr-row {
|
||||
display: flex; gap: .5rem; align-items: center;
|
||||
margin-bottom: .45rem;
|
||||
}
|
||||
.rr-row select { flex: 1; }
|
||||
.rr-row input[type="text"]:not(.rr-emoji) { flex: 1; }
|
||||
.rr-emoji { width: 5.5rem !important; flex: none !important; text-align: center; }
|
||||
|
||||
/* Kleine Buttons in Listen-Zeilen + Ghost-Variante */
|
||||
.btn-mini {
|
||||
font-family: var(--mono);
|
||||
font-size: .6rem; letter-spacing: .15em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
border-radius: 5px;
|
||||
padding: .25rem .6rem;
|
||||
white-space: nowrap;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.btn-mini:hover { color: var(--neon); border-color: var(--neon); }
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
border-color: rgba(255, 255, 255, .12);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--text); background: rgba(255, 255, 255, .04); box-shadow: none; }
|
||||
|
||||
/* Feedback-Toast unten rechts */
|
||||
.feedback-float {
|
||||
position: fixed;
|
||||
right: 1.5rem; bottom: 1.5rem;
|
||||
z-index: 500;
|
||||
font-family: var(--mono);
|
||||
font-size: .78rem; letter-spacing: .1em;
|
||||
color: var(--success);
|
||||
background: #0d0d0d;
|
||||
border: 1px solid rgba(0, 255, 136, .3);
|
||||
padding: .6rem 1.1rem;
|
||||
animation: riseIn .25s ease both;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-layout { flex-direction: column; }
|
||||
.settings-nav {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: .35rem;
|
||||
padding-bottom: .35rem;
|
||||
}
|
||||
.settings-nav-item {
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
padding: .45rem .7rem;
|
||||
}
|
||||
.settings-nav-item.active { border-left: none; border-bottom-color: var(--neon); }
|
||||
}
|
||||
|
||||
/* ── SETTINGS ──────────────────────────────────────── */
|
||||
.settings-section {
|
||||
background: var(--bg2);
|
||||
@@ -727,3 +836,48 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
.commit-row { flex-wrap: wrap; row-gap: .35rem; }
|
||||
.commit-msg { flex-basis: 100%; order: -1; white-space: normal; }
|
||||
}
|
||||
|
||||
/* ── MODERN POLISH ─────────────────────────────────────
|
||||
Weiche Radien + feinere Flächen, überschreibt gezielt
|
||||
die eckigen Basis-Styles weiter oben. */
|
||||
.card, .settings-section, .commit-list, .key-list, .key-created,
|
||||
.wish-row, .release-card, .milestone {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.settings-section {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .015), transparent 40%), var(--bg2);
|
||||
border-color: rgba(255, 255, 255, .08);
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.settings-section:hover { border-color: rgba(245, 197, 24, .18); }
|
||||
.settings-section + .settings-section,
|
||||
.settings-content > .settings-section { margin-bottom: 1.25rem; }
|
||||
|
||||
.btn, .btn-login, .field select, .field input[type="text"], .field textarea,
|
||||
.search, .user-chip, .feedback-float, .sha, .card-project, .card-delete,
|
||||
.commits th:first-child, .commits th:last-child {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.chip, .key-scopes { border-radius: 99px; }
|
||||
.settings-nav-item { border-radius: 9px; border-left-width: 2px; }
|
||||
.key-list .key-row:first-child { border-radius: 12px 12px 0 0; }
|
||||
.key-list .key-row:last-child { border-radius: 0 0 12px 12px; }
|
||||
.commit-list .commit-row:first-child { border-radius: 12px 12px 0 0; }
|
||||
.commit-list .commit-row:last-child { border-radius: 0 0 12px 12px; }
|
||||
.card-images a, .shot, .progress, .hm-cell { border-radius: 6px; }
|
||||
.card-images.n1 a { border-radius: 10px; }
|
||||
|
||||
/* Inputs: ruhigerer Grundzustand, klarer Fokus */
|
||||
.field select, .field input[type="text"], .field textarea {
|
||||
background: rgba(0, 0, 0, .35);
|
||||
border-color: rgba(255, 255, 255, .09);
|
||||
}
|
||||
.field select:hover, .field input[type="text"]:hover, .field textarea:hover {
|
||||
border-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
.field select:focus, .field input[type="text"]:focus, .field textarea:focus {
|
||||
border-color: var(--neon);
|
||||
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
|
||||
}
|
||||
.toggle input { border-radius: 99px; }
|
||||
.toggle input::after { border-radius: 50%; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId }
|
||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||
import { registerCommunityListeners } from './community.js';
|
||||
import { registerModTools } from './mod-tools.js';
|
||||
import { handleRoleMenuButton } from './role-menus.js';
|
||||
import {
|
||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||
@@ -123,6 +124,19 @@ export async function startBot() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rollen-Menü-Buttons: rolemenu:<menuId>:<roleId>
|
||||
if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) {
|
||||
try {
|
||||
await handleRoleMenuButton(interaction);
|
||||
} catch (error) {
|
||||
console.error('[rollen] Toggle fehlgeschlagen:', error);
|
||||
await interaction
|
||||
.reply({ content: '❌ Rolle konnte nicht geändert werden — Bot-Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral })
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎫-Button: Ticket erstellen (privater Thread) / schließen
|
||||
if (interaction.isButton() && interaction.customId === 'ticket_create') {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Rollen-Menüs (Carl-Bot-Ersatz): Der Bot postet ein Embed mit Rollen-Buttons,
|
||||
// Member weisen sich die Rollen selbst zu (Toggle, optional exklusiv = max. 1 Rolle
|
||||
// pro Menü, z. B. für Farb- oder Plattform-Rollen). Verwaltet über die /rollen-Seite.
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags } from 'discord.js';
|
||||
import { getRoleMenu, setRoleMenuMessage } from '../db.js';
|
||||
|
||||
const BRAND_YELLOW = 0xf5c518;
|
||||
export const MAX_ENTRIES = 25; // Discord: 5 Button-Reihen à 5 Buttons
|
||||
|
||||
/** Embed + Button-Reihen aus einem Menü-Datensatz bauen */
|
||||
function buildMenuMessage(menu) {
|
||||
const entries = JSON.parse(menu.entries || '[]');
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(BRAND_YELLOW)
|
||||
.setTitle(menu.title)
|
||||
.setDescription(
|
||||
(menu.description?.trim() ? `${menu.description.trim()}\n\n` : '') +
|
||||
(menu.exclusive
|
||||
? '*Klick = Rolle nehmen — es ist immer nur eine Rolle aus diesem Menü aktiv.*'
|
||||
: '*Klick = Rolle nehmen, nochmal klicken = wieder abgeben.*')
|
||||
)
|
||||
.setFooter({ text: 'D4RKST3R // ROLLEN' });
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < entries.length; i += 5) {
|
||||
rows.push(
|
||||
new ActionRowBuilder().addComponents(
|
||||
entries.slice(i, i + 5).map((e) => {
|
||||
const button = new ButtonBuilder()
|
||||
.setCustomId(`rolemenu:${menu.id}:${e.role_id}`)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setLabel(e.label.slice(0, 80));
|
||||
if (e.emoji) button.setEmoji(e.emoji);
|
||||
return button;
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
return { embeds: [embed], components: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Menü in seinen Kanal posten bzw. den bestehenden Post aktualisieren.
|
||||
* @returns {Promise<string>} Message-ID des Posts
|
||||
*/
|
||||
export async function publishRoleMenu(client, menuId) {
|
||||
const menu = getRoleMenu(menuId);
|
||||
if (!menu) throw new Error('Menü nicht gefunden');
|
||||
if (!menu.channel_id) throw new Error('Kein Kanal gewählt');
|
||||
const entries = JSON.parse(menu.entries || '[]');
|
||||
if (entries.length === 0) throw new Error('Menü hat keine Rollen');
|
||||
|
||||
const channel = await client.channels.fetch(menu.channel_id);
|
||||
if (!channel?.isTextBased()) throw new Error('Kanal nicht gefunden');
|
||||
|
||||
const payload = buildMenuMessage(menu);
|
||||
|
||||
if (menu.message_id) {
|
||||
const existing = await channel.messages.fetch(menu.message_id).catch(() => null);
|
||||
if (existing) {
|
||||
await existing.edit(payload);
|
||||
return menu.message_id;
|
||||
}
|
||||
}
|
||||
const message = await channel.send(payload);
|
||||
setRoleMenuMessage(menu.id, message.id);
|
||||
return message.id;
|
||||
}
|
||||
|
||||
/** Discord-Post eines Menüs löschen (beim Menü-Löschen) */
|
||||
export async function unpublishRoleMenu(client, menu) {
|
||||
if (!menu.channel_id || !menu.message_id) return;
|
||||
const channel = await client.channels.fetch(menu.channel_id).catch(() => null);
|
||||
const message = await channel?.messages?.fetch(menu.message_id).catch(() => null);
|
||||
await message?.delete().catch(() => {});
|
||||
}
|
||||
|
||||
/** Button-Klick verarbeiten: rolemenu:<menuId>:<roleId> */
|
||||
export async function handleRoleMenuButton(interaction) {
|
||||
const [, menuId, roleId] = interaction.customId.split(':');
|
||||
const menu = getRoleMenu(Number(menuId));
|
||||
const entries = menu ? JSON.parse(menu.entries || '[]') : [];
|
||||
const entry = entries.find((e) => e.role_id === roleId);
|
||||
if (!menu || !entry) {
|
||||
await interaction.reply({ content: '❌ Dieses Menü ist nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = interaction.member;
|
||||
const hasRole = member.roles.cache.has(roleId);
|
||||
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId);
|
||||
await interaction.reply({
|
||||
content: `🚪 Rolle **${entry.label}** abgegeben.`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Exklusiv-Modus: andere Rollen dieses Menüs vorher entfernen
|
||||
if (menu.exclusive) {
|
||||
for (const other of entries) {
|
||||
if (other.role_id !== roleId && member.roles.cache.has(other.role_id)) {
|
||||
await member.roles.remove(other.role_id).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await member.roles.add(roleId);
|
||||
await interaction.reply({
|
||||
content: `✅ Rolle **${entry.label}** genommen!`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
@@ -252,6 +252,38 @@ export function commitHeatmap() {
|
||||
.all();
|
||||
}
|
||||
|
||||
// Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS role_menus (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
channel_id TEXT,
|
||||
message_id TEXT,
|
||||
exclusive INTEGER NOT NULL DEFAULT 0,
|
||||
entries TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
`);
|
||||
const insertRoleMenu = db.prepare(`
|
||||
INSERT INTO role_menus (title, description, channel_id, exclusive, entries)
|
||||
VALUES (@title, @description, @channel_id, @exclusive, @entries)
|
||||
`);
|
||||
const updateRoleMenuStmt = db.prepare(`
|
||||
UPDATE role_menus SET title = @title, description = @description,
|
||||
channel_id = @channel_id, exclusive = @exclusive, entries = @entries
|
||||
WHERE id = @id
|
||||
`);
|
||||
const setRoleMenuMessageStmt = db.prepare('UPDATE role_menus SET message_id = ? WHERE id = ?');
|
||||
const getRoleMenuStmt = db.prepare('SELECT * FROM role_menus WHERE id = ?');
|
||||
const listRoleMenusStmt = db.prepare('SELECT * FROM role_menus ORDER BY id');
|
||||
const deleteRoleMenuStmt = db.prepare('DELETE FROM role_menus WHERE id = ?');
|
||||
export const createRoleMenu = (m) => insertRoleMenu.run(m).lastInsertRowid;
|
||||
export const updateRoleMenu = (m) => updateRoleMenuStmt.run(m).changes > 0;
|
||||
export const setRoleMenuMessage = (id, messageId) => setRoleMenuMessageStmt.run(messageId, id);
|
||||
export const getRoleMenu = (id) => getRoleMenuStmt.get(id) ?? null;
|
||||
export const listRoleMenus = () => listRoleMenusStmt.all();
|
||||
export const deleteRoleMenu = (id) => deleteRoleMenuStmt.run(id).changes > 0;
|
||||
|
||||
// Ticket-System (Button → privater Thread)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
|
||||
+108
@@ -4,7 +4,9 @@ import {
|
||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||
} from '../db.js';
|
||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||
import { config } from '../config.js';
|
||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
|
||||
@@ -313,6 +315,112 @@ ${rssItems}
|
||||
return { ok: true, settings: currentSettings() };
|
||||
});
|
||||
|
||||
// --- Rollen-Menüs (Admin) — Carl-Bot-Ersatz ---
|
||||
|
||||
/** Menü-Body validieren + normalisieren; sendet bei Fehler selbst die Antwort */
|
||||
function parseMenuBody(request, reply) {
|
||||
const body = request.body ?? {};
|
||||
const title = String(body.title ?? '').trim();
|
||||
if (!title) {
|
||||
reply.code(400).send({ error: 'title nötig' });
|
||||
return null;
|
||||
}
|
||||
const entries = (Array.isArray(body.entries) ? body.entries : [])
|
||||
.map((e) => ({
|
||||
emoji: String(e.emoji ?? '').trim(),
|
||||
label: String(e.label ?? '').trim(),
|
||||
role_id: String(e.role_id ?? '').trim(),
|
||||
}))
|
||||
.filter((e) => e.label && e.role_id);
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
reply.code(400).send({ error: `maximal ${MAX_ENTRIES} Rollen pro Menü` });
|
||||
return null;
|
||||
}
|
||||
// Rollen müssen existieren, keine Duplikate
|
||||
const seen = new Set();
|
||||
for (const e of entries) {
|
||||
if (seen.has(e.role_id)) {
|
||||
reply.code(400).send({ error: 'doppelte Rolle im Menü' });
|
||||
return null;
|
||||
}
|
||||
seen.add(e.role_id);
|
||||
const exists = [...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(e.role_id));
|
||||
if (!exists) {
|
||||
reply.code(400).send({ error: `Rolle ${e.role_id} nicht gefunden` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const channelId = String(body.channel_id ?? '').trim();
|
||||
if (channelId && !client.channels.cache.get(channelId)?.isTextBased?.()) {
|
||||
reply.code(400).send({ error: 'Kanal nicht gefunden' });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: title.slice(0, 200),
|
||||
description: String(body.description ?? '').trim().slice(0, 2000),
|
||||
channel_id: channelId || null,
|
||||
exclusive: body.exclusive ? 1 : 0,
|
||||
entries: JSON.stringify(entries),
|
||||
};
|
||||
}
|
||||
|
||||
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
|
||||
|
||||
app.get('/api/rolemenus', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { menus: listRoleMenus().map(menuToJson) };
|
||||
});
|
||||
|
||||
app.post('/api/rolemenus', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const menu = parseMenuBody(request, reply);
|
||||
if (!menu) return;
|
||||
const id = createRoleMenu(menu);
|
||||
request.log.info(`Rollen-Menü ${id} erstellt`);
|
||||
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
|
||||
});
|
||||
|
||||
app.put('/api/rolemenus/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
|
||||
const menu = parseMenuBody(request, reply);
|
||||
if (!menu) return;
|
||||
updateRoleMenu({ ...menu, id });
|
||||
// Bereits gepostet? Dann den Discord-Post direkt mitziehen
|
||||
let published = false;
|
||||
if (getRoleMenu(id).message_id) {
|
||||
try {
|
||||
await publishRoleMenu(client, id);
|
||||
published = true;
|
||||
} catch (error) {
|
||||
request.log.warn(`Menü ${id}: Discord-Update fehlgeschlagen: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return { ok: true, published, menu: menuToJson(getRoleMenu(id)) };
|
||||
});
|
||||
|
||||
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
try {
|
||||
const messageId = await publishRoleMenu(client, Number(request.params.id));
|
||||
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
|
||||
return { ok: true, message_id: messageId };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/rolemenus/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const menu = getRoleMenu(Number(request.params.id));
|
||||
if (!menu) return { deleted: false };
|
||||
await unpublishRoleMenu(client, menu);
|
||||
deleteRoleMenu(menu.id);
|
||||
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
||||
app.post('/api/compose', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
|
||||
@@ -48,6 +48,8 @@ export async function startWebServer(client) {
|
||||
// JSON als Buffer parsen, damit wir den Raw-Body für die Signatur-Prüfung behalten
|
||||
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
||||
req.rawBody = body;
|
||||
// Leerer Body (z. B. DELETE mit gesetztem Content-Type) → leeres Objekt statt Parse-Fehler
|
||||
if (body.length === 0) return done(null, {});
|
||||
try {
|
||||
done(null, JSON.parse(body.toString('utf8')));
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user