diff --git a/README.md b/README.md
index a2ccd7d..b12bde8 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index b80a618..b6a725c 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -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 = (
SETUP
@@ -117,7 +188,7 @@ export default function Settings({ me }) {
// Admin
Setup
-
Kanäle, Feed-Regeln und Status — Änderungen greifen sofort.
+
Alles an einem Ort — Änderungen greifen sofort.
);
@@ -140,494 +211,478 @@ export default function Settings({ me }) {
if (!data || !form) return <>{header}>;
const channelOptions = data.channels.map((c) => (
-
- #{c.name} — {c.guild}
-
+ #{c.name} — {c.guild}
));
+ const roleOptions = (data.roles ?? []).map((r) => (
+ @{r.name} — {r.guild}
+ ));
+ const channelName = (id) => {
+ const c = data.channels.find((x) => x.id === id);
+ return c ? `#${c.name}` : id;
+ };
+
+ /* ── Tab-Inhalte ───────────────────────────────── */
+
+ const tabStatus = (
+
+
// Status
+
+
{data.status.botTag ?? '—'} Bot
+
{fmtUptime(data.status.uptimeSeconds)} Uptime
+
{data.status.devlogs} Devlogs
+
{data.status.commits} Commits
+
{fmtBytes(data.status.dbSizeBytes)} Datenbank
+
+ {data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}
+ Letztes Backup
+
+
+
+ );
+
+ const tabFeeds = (
+ <>
+
+
// Kanäle
+
+
Devlog-Kanal öffentlich — hier postet der Bot die Devlogs
+
+ setForm({ ...form, devlog_channel_id: e.target.value })}>
+ — Kanal wählen —
+ {channelOptions}
+
+ sendTest('devlog')}>Test
+
+
+
+
Commit-Kanal privat — hier landen die Push-Embeds
+
+ setForm({ ...form, commit_channel_id: e.target.value })}>
+ — Kanal wählen —
+ {channelOptions}
+
+ sendTest('commit')}>Test
+
+
+
+
Release-Kanal Ankündigungen bei neuen Gitea-Releases — leer = aus
+
+ setForm({ ...form, release_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+ sendTest('release')}>Test
+
+
+
+
+
+
// Devlog
+
+ Ping-Rolle wird bei jedem Devlog erwähnt; 🔔-Button am Post — leer = kein Ping
+ setForm({ ...form, devlog_ping_role_id: e.target.value })}>
+ — kein Ping —
+ {roleOptions}
+
+
+
+
+ setForm({ ...form, weekly_recap_enabled: e.target.checked })} />
+ Wochen-Rückblick (sonntags 20:00)
+ Commits pro Tag, Devlogs & Top-Projekte
+
+
+
+
+ setForm({ ...form, devlog_threads_enabled: e.target.checked })} />
+ Diskussions-Thread unter jedem Devlog
+ „💬 Devlog <Datum>"
+
+
+
+ sendTest('recap')}>Rückblick jetzt testen
+
+
+
+
+ >
+ );
+
+ const tabCommunity = (
+ <>
+
+
// Playtester
+
+ Playtester-Rolle Bewerbungs-Post via /playtester-setup; leer = aus
+ setForm({ ...form, playtester_role_id: e.target.value })}>
+ — deaktiviert —
+ {roleOptions}
+
+
+ {playtesters.length > 0 && (
+
+ {playtesters.map((p) => (
+
+ {p.username}
+ seit {p.applied_at.slice(0, 10)}
+
+ ))}
+
+ )}
+
+
+
+
// Starboard & Galerie
+
+
Starboard-Kanal Nachrichten mit genug ⭐ landen hier
+
+ setForm({ ...form, starboard_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+ setForm({ ...form, starboard_threshold: e.target.value })} />
+
+
+
+ Screenshot-Kanal Bilder → öffentliche Galerie; Historie: /galerie-backfill
+ setForm({ ...form, screenshot_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ Voting-Kanal /wunsch postet hier; Rangliste auf /roadmap
+ setForm({ ...form, voting_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ >
+ );
+
+ const tabRollen = (
+ <>
+
+
// Rollen-Menüs
+
+ 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.
+
+
+ {menus.length > 0 && (
+
+ {menus.map((m) => (
+
+ {m.title}
+
+ {m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'}
+ {m.exclusive ? ' · exklusiv' : ''}
+
+
+ {m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'}
+
+ editMenu(m)}>Bearbeiten
+ removeMenu(m.id)}>✕
+
+ ))}
+
+ )}
+
+
+
{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}
+
+ setMenuDraft({ ...menuDraft, title: e.target.value })} />
+ setMenuDraft({ ...menuDraft, channel_id: e.target.value })}>
+ — Kanal wählen —
+ {channelOptions}
+
+
+
+
+ Beschreibung optional, steht im Embed über den Buttons
+ setMenuDraft({ ...menuDraft, description: e.target.value })} />
+
+
+
+ setMenuDraft({ ...menuDraft, exclusive: e.target.checked })} />
+ Exklusiv
+ max. eine Rolle aus diesem Menü (z. B. Farb-Rollen)
+
+
+
+
+
Rollen Emoji optional (auch Custom als <:name:id>), Label = Button-Text
+ {menuDraft.entries.map((entry, i) => (
+
+ setMenuEntry(i, { emoji: e.target.value })} />
+ setMenuEntry(i, { label: e.target.value })} />
+ setMenuEntry(i, { role_id: e.target.value })}>
+ — Rolle —
+ {roleOptions}
+
+ setMenuDraft({ ...menuDraft, entries: menuDraft.entries.filter((_, j) => j !== i) })}
+ disabled={menuDraft.entries.length === 1}
+ >✕
+
+ ))}
+
+ setMenuDraft({ ...menuDraft, entries: [...menuDraft.entries, { emoji: '', label: '', role_id: '' }] })} disabled={menuDraft.entries.length >= 25}>
+ + Rolle
+
+ saveMenu(false)}>Speichern
+ saveMenu(true)}>Speichern + posten
+ {menuDraft.id && (
+ setMenuDraft(emptyMenu)}>Abbrechen
+ )}
+
+
+
+ >
+ );
+
+ const tabSupport = (
+
+
// Moderation & Kontakt
+
+ Modmail-Kanal PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads
+ setForm({ ...form, modmail_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ Ticket-Kanal private Ticket-Threads; Post via /ticket-setup
+ setForm({ ...form, ticket_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ Willkommens-Kanal Begrüßungs-Embed für neue Member
+ setForm({ ...form, welcome_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ Mod-Log-Kanal PRIVAT — gelöschte/bearbeitete Nachrichten
+ setForm({ ...form, modlog_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ );
+
+ const tabComposer = (
+
+
// Post verfassen
+
+ Kanal
+ setComposer({ ...composer, channel: e.target.value })}>
+ — Kanal wählen —
+ {channelOptions}
+
+
+
+ Embed-Titel leer = normale Nachricht statt Embed
+ setComposer({ ...composer, title: e.target.value })} />
+
+
+ Text Discord-Markdown (##, **, Listen …)
+
+
+
Message-ID optional — vorhandenen Bot-Post bearbeiten
+
+ setComposer({ ...composer, messageId: e.target.value })} />
+
+ {composer.messageId.trim() ? 'Aktualisieren' : 'Senden'}
+
+
+
+
+ );
+
+ const tabSystem = (
+ <>
+
+
+
+
// Game-Server
+
+ Status-Kanal persistentes Status-Embed, alle 2 min
+ setForm({ ...form, status_channel_id: e.target.value })}>
+ — deaktiviert —
+ {channelOptions}
+
+
+
+ Server kommagetrennt Name=URL — FiveM-kompatibel (/dynamic.json)
+ setForm({ ...form, gameservers: e.target.value })} />
+
+
+
+
+
// Backups & Watchdog
+
+
+ setForm({ ...form, backup_enabled: e.target.checked })} />
+ Nächtliches DB-Backup (03:00)
+ gzip, 14 Tage Rotation
+
+
+
+
Backup-Upload-Kanal Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal
+
+ setForm({ ...form, backup_channel_id: e.target.value })}>
+ — nur lokal —
+ {channelOptions}
+
+ sendTest('backup')}>Backup jetzt
+
+
+
+ Watchdog-URLs kommagetrennt; DM bei Ausfall — leer = aus
+ setForm({ ...form, watchdog_urls: e.target.value })} />
+
+
+ >
+ );
+
+ const tabApi = (
+
+
// API-Keys (/api/v1)
+ {keys.length > 0 && (
+
+ {keys.map((k) => (
+
+ {k.name}
+ {k.scopes}
+
+ {k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
+
+ revokeKey(k.id)}>✕
+
+ ))}
+
+ )}
+ {createdKey && (
+
+
Key {createdKey.name} erstellt — jetzt kopieren , er wird nie wieder angezeigt:
+
{createdKey.key}
+
+ )}
+
+
Neuer Key Name des Skripts/Dienstes + Scopes
+
+ setNewKeyName(e.target.value)} />
+ Erstellen
+
+
+ {API_SCOPES.map((s) => (
+
+
+ setNewKeyScopes(
+ e.target.checked
+ ? [...newKeyScopes, s.id]
+ : newKeyScopes.filter((x) => x !== s.id)
+ )
+ }
+ />
+ {s.label}
+
+ ))}
+
+
+
+ );
+
+ const content = {
+ status: tabStatus,
+ feeds: tabFeeds,
+ community: tabCommunity,
+ rollen: tabRollen,
+ support: tabSupport,
+ composer: tabComposer,
+ system: tabSystem,
+ api: tabApi,
+ }[tab];
+
+ // Tabs, deren Inhalte über den zentralen Speichern-Button laufen
+ const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
return (
<>
{header}
-
- {/* Status */}
-
-
// Status
-
-
{data.status.botTag ?? '—'} Bot
-
{fmtUptime(data.status.uptimeSeconds)} Uptime
-
{data.status.devlogs} Devlogs
-
{data.status.commits} Commits
-
{fmtBytes(data.status.dbSizeBytes)} Datenbank
-
- {data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}
- Letztes Backup
-
-
-
-
- {/* Allgemein */}
-
-
// Allgemein
-
- Öffentliche URL Basis für Buttons, RSS und OAuth-Redirect (Redirect auch im Dev-Portal eintragen)
- setForm({ ...form, public_url: e.target.value })}
- />
-
-
- Gitea-URL Basis für die Issue-API (/bug)
- setForm({ ...form, gitea_url: e.target.value })}
- />
-
-
-
- {/* Kanäle */}
-
-
// Kanäle
-
-
-
Devlog-Kanal öffentlich — hier postet der Bot die Devlogs
-
-
setForm({ ...form, devlog_channel_id: e.target.value })}
+
+
+
+ {TABS.map((t) => (
+ setTab(t.id)}
>
- — Kanal wählen —
- {channelOptions}
-
- sendTest('devlog')}>Test senden
-
-
+
{t.icon}
+ {t.label}
+
+ ))}
+
-
-
Commit-Kanal privat — hier landen die Push-Embeds
-
- setForm({ ...form, commit_channel_id: e.target.value })}
- >
- — Kanal wählen —
- {channelOptions}
-
- sendTest('commit')}>Test senden
-
-
-
-
-
Release-Kanal Ankündigungen bei neuen Gitea-Releases — leer = aus
-
- setForm({ ...form, release_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
- sendTest('release')}>Test senden
-
-
-
-
- {/* Devlog */}
-
-
// Devlog
-
- Ping-Rolle wird bei jedem Devlog-Post erwähnt; Member abonnieren sie über den 🔔-Button am Post — leer = kein Ping
- setForm({ ...form, devlog_ping_role_id: e.target.value })}
- >
- — kein Ping —
- {(data.roles ?? []).map((r) => (
- @{r.name} — {r.guild}
- ))}
-
-
-
-
- setForm({ ...form, weekly_recap_enabled: e.target.checked })}
- />
- Wochen-Rückblick (sonntags 20:00)
- Commits pro Tag, Devlogs & Top-Projekte in den Devlog-Kanal
-
-
-
-
- setForm({ ...form, devlog_threads_enabled: e.target.checked })}
- />
- Diskussions-Thread unter jedem Devlog
- Bot öffnet automatisch „💬 Devlog <Datum>"
-
-
-
- sendTest('recap')}>Rückblick jetzt testen
-
-
-
- {/* Bug-Reports */}
-
-
// Bug-Reports (/bug)
- {!data.status.giteaTokenConfigured && (
-
- ⚠️ Kein GITEA_API_TOKEN gesetzt — /bug ist deaktiviert.
-
- )}
-
- Ziel-Repo hier landen die Issues aus /bug
- setForm({ ...form, bug_report_repo: e.target.value })}
- />
-
-
- Roadmap-Repo Milestones dieses Repos speisen die öffentliche /roadmap-Seite
- setForm({ ...form, roadmap_repo: e.target.value })}
- />
-
-
-
- {/* Watchdog */}
-
-
// Watchdog
-
- Überwachte URLs kommagetrennt; bei Ausfall gibt's eine DM — leer = aus
- setForm({ ...form, watchdog_urls: e.target.value })}
- />
-
-
-
- {/* Community */}
-
-
// Community
-
-
-
Playtester-Rolle Bewerbungs-Post via /playtester-setup; leer = aus
-
setForm({ ...form, playtester_role_id: e.target.value })}
- >
- — deaktiviert —
- {(data.roles ?? []).map((r) => (
- @{r.name} — {r.guild}
- ))}
-
- {playtesters.length > 0 && (
-
- {playtesters.map((p) => (
-
- {p.username}
- seit {p.applied_at.slice(0, 10)}
-
- ))}
+
+ {content}
+ {showSave && (
+
+ Speichern
)}
-
-
-
Starboard-Kanal Nachrichten mit genug ⭐ landen hier; leer = aus
-
- setForm({ ...form, starboard_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
- setForm({ ...form, starboard_threshold: e.target.value })}
- />
-
-
-
-
- Screenshot-Kanal Bilder landen in der öffentlichen Galerie; Historie: /galerie-backfill
- setForm({ ...form, screenshot_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
- Voting-Kanal /wunsch postet hier; Rangliste auf der Roadmap-Seite
- setForm({ ...form, voting_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
-
- {/* Moderation & Kontakt */}
-
-
// Moderation & Kontakt
-
- Modmail-Kanal PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads
- setForm({ ...form, modmail_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
- Willkommens-Kanal Begrüßungs-Embed für neue Member (braucht Server-Members-Intent im Dev-Portal!)
- setForm({ ...form, welcome_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
- Mod-Log-Kanal PRIVAT — gelöschte/bearbeitete Nachrichten
- setForm({ ...form, modlog_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
- Ticket-Kanal hier entstehen die privaten Ticket-Threads; Post via /ticket-setup
- setForm({ ...form, ticket_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
-
- {/* Composer */}
-
-
// Post verfassen
-
- Kanal
- setComposer({ ...composer, channel: e.target.value })}>
- — Kanal wählen —
- {channelOptions}
-
-
-
- Embed-Titel leer = normale Nachricht statt Embed
- setComposer({ ...composer, title: e.target.value })}
- />
-
-
- Text Discord-Markdown (##, **, Listen …)
-
-
-
Message-ID optional — vorhandenen Bot-Post bearbeiten statt neu senden
-
- setComposer({ ...composer, messageId: e.target.value })}
- />
-
- {composer.messageId.trim() ? 'Aktualisieren' : 'Senden'}
-
-
-
-
-
- {/* Game-Server */}
-
-
// Game-Server
-
- Status-Kanal persistentes Status-Embed, alle 2 min aktualisiert
- setForm({ ...form, status_channel_id: e.target.value })}
- >
- — deaktiviert —
- {channelOptions}
-
-
-
- Server kommagetrennt Name=URL — FiveM-kompatibel (/dynamic.json)
- setForm({ ...form, gameservers: e.target.value })}
- />
-
-
-
- {/* Backups */}
-
-
// Backups
-
-
- setForm({ ...form, backup_enabled: e.target.checked })}
- />
- Nächtliches DB-Backup (03:00)
- gzip nach data/backups/, 14 Tage Rotation
-
-
-
-
Upload-Kanal Offsite-Kopie in einen PRIVATEN Kanal — leer = nur lokal
-
- setForm({ ...form, backup_channel_id: e.target.value })}
- >
- — nur lokal —
- {channelOptions}
-
- sendTest('backup')}>Backup jetzt
-
-
-
-
- {/* API-Keys */}
-
-
// API-Keys (/api/v1)
-
- {keys.length > 0 && (
-
- {keys.map((k) => (
-
- {k.name}
- {k.scopes}
-
- {k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
-
- revokeKey(k.id)}>✕
-
- ))}
-
- )}
-
- {createdKey && (
-
-
Key {createdKey.name} erstellt — jetzt kopieren , er wird nie wieder angezeigt:
-
{createdKey.key}
-
- )}
-
-
-
Neuer Key Name des Skripts/Dienstes + Scopes
-
- setNewKeyName(e.target.value)}
- />
- Erstellen
-
-
- {API_SCOPES.map((s) => (
-
-
- setNewKeyScopes(
- e.target.checked
- ? [...newKeyScopes, s.id]
- : newKeyScopes.filter((x) => x !== s.id)
- )
- }
- />
- {s.label}
-
- ))}
-
-
-
-
- {/* Commit-Feed */}
-
-
-
- Speichern
- {feedback && {feedback} }
+ {feedback &&
{feedback}
}
>
);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 6279937..33c91e7 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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%; }
diff --git a/src/bot/client.js b/src/bot/client.js
index fcf5f8c..c75eb03 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -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:
:
+ 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 {
diff --git a/src/bot/role-menus.js b/src/bot/role-menus.js
new file mode 100644
index 0000000..8659459
--- /dev/null
+++ b/src/bot/role-menus.js
@@ -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} 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:: */
+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,
+ });
+}
diff --git a/src/db.js b/src/db.js
index e27aa01..a990bb7 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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 (
diff --git a/src/web/api.js b/src/web/api.js
index a1c8707..4a99c53 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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;
diff --git a/src/web/server.js b/src/web/server.js
index 21ef463..f27837c 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -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) {