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:
2026-07-30 00:15:29 +02:00
co-authored by Claude Fable 5
parent 4f23f821c5
commit a27a594a07
8 changed files with 982 additions and 499 deletions
+108
View File
@@ -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;
+2
View File
@@ -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) {