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
+14
View File
@@ -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 {
+115
View File
@@ -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,
});
}