+ {bereiche.length > 0 && (
+
{});
apiGet('/api/automod').then(setAutomod).catch(() => {});
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
- apiGet('/api/wishes').then((d) => setWuensche(d.wishes ?? [])).catch(() => {});
+ apiGet('/api/wishes').then((d) => {
+ setWuensche(d.wishes ?? []);
+ setBereiche(d.bereiche ?? []);
+ }).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
@@ -1865,6 +1870,64 @@ export default function Settings({ me }) {
)}
+
// Geburtstage
@@ -2080,6 +2143,29 @@ export default function Settings({ me }) {
>
);
+ async function bereichSpeichern() {
+ try {
+ const res = bereichDraft.id
+ ? await apiPut(`/api/wish-categories/${bereichDraft.id}`, bereichDraft)
+ : await apiPost('/api/wish-categories', bereichDraft);
+ setBereiche(res.bereiche ?? []);
+ setBereichDraft({ id: null, name: '', emoji: '' });
+ } catch (e) {
+ flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
+ }
+ }
+
+ async function bereichLoeschen(b) {
+ if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return;
+ try {
+ const res = await apiDelete(`/api/wish-categories/${b.id}`);
+ setBereiche(res.bereiche ?? []);
+ if (res.wishes) setWuensche(res.wishes);
+ } catch {
+ flash('✗ Entfernen fehlgeschlagen');
+ }
+ }
+
/** Status setzen — mit Begruendung, denn genau die fehlt sonst */
function wunschStatus(wunsch, status) {
const label = Object.fromEntries(WUNSCH_STATUS)[status];
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 19309f2..5a5e57c 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -488,7 +488,31 @@ body::after {
/* Erledigtes tritt zurück, ohne zu verschwinden */
.wish-row.st-umgesetzt, .wish-row.st-abgelehnt { opacity: .72; }
-.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .9rem; }
+/* Schilder stehen über der Idee, nicht dahinter — sonst rutschen sie bei
+ langen Texten aus dem Blick */
+.wish-schilder { display: flex; gap: .35rem; flex-wrap: wrap; margin-bottom: .25rem; }
+.wish-area {
+ font-family: var(--mono); font-size: .58rem; letter-spacing: .1em;
+ text-transform: uppercase; white-space: nowrap; color: var(--muted2);
+ padding: .18rem .5rem; border-radius: 999px; border: 1px solid var(--border);
+}
+/* Kommentare liegen im Discord-Thread — der Zähler führt direkt hinein */
+.wish-komm {
+ display: inline-flex; align-items: center; gap: .3rem;
+ font-family: var(--mono); font-size: .68rem; color: var(--muted2);
+ text-decoration: none; white-space: nowrap;
+}
+.wish-komm:hover { color: var(--neon); }
+
+.wish-bar { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; }
+.wish-filter-label {
+ font-family: var(--mono); font-size: .58rem; letter-spacing: .12em;
+ text-transform: uppercase; color: var(--muted2);
+ align-self: center; margin-right: .2rem; min-width: 5.2rem;
+}
+.wish-area-select { flex: 0 0 auto; max-width: 11rem; }
+
+.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
.wish-chip {
background: var(--bg2); border: 1px solid var(--border); border-radius: 999px;
color: var(--muted); font-family: var(--mono); font-size: .62rem;
diff --git a/src/bot/client.js b/src/bot/client.js
index 4df673f..2df4569 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -20,6 +20,9 @@ import {
claimTicketButton, unclaimTicketButton,
} from './tickets.js';
import { handleRoleMenuButton } from './role-menus.js';
+import {
+ handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads,
+} from './wishes.js';
import { handleGiveawayAdminButton } from './giveaways.js';
import {
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
@@ -31,6 +34,7 @@ import * as bug from './commands/bug.js';
import * as playtesterSetup from './commands/playtester-setup.js';
import * as galerieBackfill from './commands/galerie-backfill.js';
import * as wunsch from './commands/wunsch.js';
+import * as wunschSetup from './commands/wunsch-setup.js';
import * as giveaway from './commands/giveaway.js';
import * as ticketSetup from './commands/ticket-setup.js';
import * as warn from './commands/warn.js';
@@ -47,7 +51,7 @@ import * as umfrage from './commands/umfrage.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
- wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
+ wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
raid, umfrage,
];
@@ -196,9 +200,22 @@ export async function startBot() {
return;
}
- // Eingabefenster von /bug und /wunsch
- if (interaction.isModalSubmit() && (interaction.customId === 'bugmodal' || interaction.customId === 'wunschmodal')) {
- const handler = interaction.customId === 'bugmodal' ? bug.handleModal : wunsch.handleModal;
+ // Ideen einreichen: Knopf oder Auswahl im Aufruf-Post -> Eingabefenster
+ if ((interaction.isButton() && interaction.customId === 'wunsch_neu')
+ || (interaction.isStringSelectMenu() && interaction.customId === 'wunsch_pick')) {
+ try {
+ if (interaction.isButton()) await handleWunschButton(interaction);
+ else await handleWunschPick(interaction);
+ } catch (error) {
+ console.error('[voting] Eingabefenster fehlgeschlagen:', error);
+ }
+ return;
+ }
+
+ // Eingabefenster von /bug und /wunsch (der Bereich haengt hinter dem Doppelpunkt)
+ if (interaction.isModalSubmit()
+ && (interaction.customId === 'bugmodal' || interaction.customId.startsWith('wunschmodal'))) {
+ const handler = interaction.customId === 'bugmodal' ? bug.handleModal : handleWunschModal;
try {
await handler(interaction);
} catch (error) {
@@ -374,6 +391,7 @@ export async function startBot() {
// Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit
registerAntiRaid(client);
registerAutoMod(client);
+ registerWishThreads(client);
registerLevels(client);
registerPresence(client);
registerExtras(client);
diff --git a/src/bot/commands/wunsch-setup.js b/src/bot/commands/wunsch-setup.js
new file mode 100644
index 0000000..96d3041
--- /dev/null
+++ b/src/bot/commands/wunsch-setup.js
@@ -0,0 +1,39 @@
+// /wunsch-setup — postet den Ideen-Aufruf in den aktuellen Kanal (Admin).
+//
+// Gibt es Bereiche, wird daraus ein Auswahlmenü: erst wohin, dann das
+// Eingabefenster. Ohne Bereiche bleibt es beim einen Knopf.
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
+import { votingChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
+import { renderTemplate } from '../../templates.js';
+import { listWishCategories } from '../../db.js';
+import { wunschPanelComponents } from '../wishes.js';
+
+export const data = new SlashCommandBuilder()
+ .setName('wunsch-setup')
+ .setDescription('Postet den Ideen-Aufruf mit Knopf in diesen Kanal')
+ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
+
+export async function execute(interaction) {
+ if (!votingChannelId()) {
+ await interaction.reply({
+ content: '❌ Erst in der Config einen Voting-Kanal wählen.',
+ flags: MessageFlags.Ephemeral,
+ });
+ return;
+ }
+
+ const embed = new EmbedBuilder()
+ .setColor(brandColor())
+ .setTitle(renderTemplate('wish.panel_title'))
+ .setDescription(renderTemplate('wish.panel_text'))
+ .setFooter({ text: brandFooter('VOTING') });
+
+ const anzahl = listWishCategories().length;
+ await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() });
+ await interaction.reply({
+ content: anzahl > 0
+ ? `✅ Aufruf gepostet — mit Auswahl aus ${anzahl} Bereichen.`
+ : '✅ Aufruf gepostet. Bereiche zum Einsortieren legst du in der Config unter Community an.',
+ flags: MessageFlags.Ephemeral,
+ });
+}
diff --git a/src/bot/commands/wunsch.js b/src/bot/commands/wunsch.js
index 5e7cf2f..74d73db 100644
--- a/src/bot/commands/wunsch.js
+++ b/src/bot/commands/wunsch.js
@@ -1,21 +1,40 @@
// /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍,
-// Rangliste auf der Webseite.
+// Thread zum Reden, Rangliste auf der Webseite.
//
// Das Fenster statt einer Slash-Option, weil eine Idee selten in eine Zeile
// passt — und weil die Rückfrage „warum wäre das gut?" die Vorschläge
// deutlich besser macht als ein Einzeiler.
-import {
- SlashCommandBuilder, MessageFlags, EmbedBuilder,
- ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle,
-} from 'discord.js';
-import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
-import { saveWish } from '../../db.js';
-import { renderTemplate } from '../../templates.js';
+//
+// Das Posten selbst steckt in bot/wishes.js, damit Befehl, Knopf unter dem
+// Aufruf-Post und Webseite denselben Weg gehen und ein Wunsch überall gleich
+// aussieht.
+import { SlashCommandBuilder, MessageFlags } from 'discord.js';
+import { votingChannelId } from '../../runtime-settings.js';
+import { listWishCategories } from '../../db.js';
+import { wunschModal } from '../wishes.js';
-export const data = new SlashCommandBuilder()
+const builder = new SlashCommandBuilder()
.setName('wunsch')
.setDescription('Feature-Wunsch einreichen — die Community stimmt ab');
+// Discord will die Auswahlmöglichkeiten schon beim Registrieren kennen. Wer
+// Bereiche nachträglich anlegt, bekommt sie hier beim nächsten Bot-Start —
+// über den Aufruf-Post (/wunsch-setup) stehen sie sofort zur Verfügung.
+{
+ const bereiche = listWishCategories();
+ if (bereiche.length > 0) {
+ builder.addStringOption((option) => option
+ .setName('bereich')
+ .setDescription('Worum geht es?')
+ .addChoices(...bereiche.slice(0, 25).map((b) => ({
+ name: `${b.emoji} ${b.name}`.trim().slice(0, 100),
+ value: String(b.id),
+ }))));
+ }
+}
+
+export const data = builder;
+
export async function execute(interaction) {
if (!votingChannelId()) {
await interaction.reply({
@@ -24,59 +43,6 @@ export async function execute(interaction) {
});
return;
}
-
- const modal = new ModalBuilder().setCustomId('wunschmodal').setTitle('Feature-Wunsch');
- modal.addComponents(
- new ActionRowBuilder().addComponents(
- new TextInputBuilder()
- .setCustomId('idee').setLabel('Deine Idee')
- .setPlaceholder('Was soll dazukommen oder anders laufen?')
- .setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true)
- ),
- new ActionRowBuilder().addComponents(
- new TextInputBuilder()
- .setCustomId('warum').setLabel('Warum wäre das gut?')
- .setPlaceholder('Was wird dadurch besser oder einfacher?')
- .setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false)
- )
- );
- await interaction.showModal(modal);
-}
-
-/** Formular abgeschickt → Voting-Post */
-export async function handleModal(interaction) {
- const channelId = votingChannelId();
- if (!channelId) {
- await interaction.reply({
- content: '❌ Feature-Voting ist gerade nicht aktiviert.',
- flags: MessageFlags.Ephemeral,
- });
- return;
- }
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
-
- const idee = interaction.fields.getTextInputValue('idee');
- const warum = interaction.fields.getTextInputValue('warum')?.trim();
- const channel = await interaction.client.channels.fetch(channelId);
-
- const embed = new EmbedBuilder()
- .setColor(brandColor())
- .setAuthor({
- name: interaction.member?.displayName ?? interaction.user.username,
- iconURL: interaction.user.displayAvatarURL({ size: 64 }),
- })
- .setTitle('💡 Feature-Wunsch')
- .setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee)
- .setURL(`${publicUrl()}/roadmap`)
- .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
-
- const message = await channel.send({ embeds: [embed] });
- await message.react('👍');
- saveWish(message.id, interaction.user.username, idee);
-
- await interaction.editReply(renderTemplate('wish.submitted', {
- user: interaction.member?.displayName ?? interaction.user.username,
- mention: `<@${interaction.user.id}>`,
- url: message.url,
- }));
+ const gewaehlt = Number(interaction.options.getString('bereich'));
+ await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
}
diff --git a/src/bot/wishes.js b/src/bot/wishes.js
new file mode 100644
index 0000000..d0a6e25
--- /dev/null
+++ b/src/bot/wishes.js
@@ -0,0 +1,154 @@
+// Feature-Wünsche: einreichen, posten, bereden.
+//
+// Ein Wunsch entsteht an drei Stellen — per /wunsch, über den Knopf unter dem
+// Aufruf-Post und auf der Webseite. Alle drei landen hier, damit ein Wunsch
+// überall gleich aussieht: gebrandetes Embed, 👍 zum Abstimmen, und ein Thread
+// darunter fürs Reden.
+//
+// Der Thread ist bewusst der Diskussionsort und nicht ein Kommentarfeld auf der
+// Webseite: die Leute sind ohnehin in Discord, und ein zweiter Ort würde die
+// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und
+// verlinkt hinein.
+import {
+ ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events,
+ ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
+} from 'discord.js';
+import {
+ saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories,
+} from '../db.js';
+import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
+import { renderTemplate } from '../templates.js';
+import { tuning } from '../tuning.js';
+import { alsEmoji } from './tickets.js';
+
+/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */
+export function wunschPanelComponents() {
+ const bereiche = listWishCategories();
+ if (bereiche.length === 0) {
+ return [new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setCustomId('wunsch_neu')
+ .setStyle(ButtonStyle.Primary)
+ .setLabel('Idee einreichen')
+ .setEmoji('💡')
+ )];
+ }
+ const menu = new StringSelectMenuBuilder()
+ .setCustomId('wunsch_pick')
+ .setPlaceholder('Worum geht es?')
+ .addOptions(bereiche.slice(0, 25).map((b) => {
+ const option = { label: b.name.slice(0, 100), value: String(b.id) };
+ const emoji = alsEmoji(b.emoji);
+ if (emoji) option.emoji = emoji;
+ return option;
+ }));
+ return [new ActionRowBuilder().addComponents(menu)];
+}
+
+/** Das Eingabefenster. Der Bereich reist in der Kennung mit. */
+export function wunschModal(categoryId = null) {
+ const bereich = categoryId ? getWishCategory(categoryId) : null;
+ return new ModalBuilder()
+ .setCustomId(`wunschmodal:${categoryId ?? ''}`)
+ .setTitle(bereich ? `Idee — ${bereich.name}`.slice(0, 45) : 'Feature-Wunsch')
+ .addComponents(
+ new ActionRowBuilder().addComponents(
+ new TextInputBuilder()
+ .setCustomId('idee').setLabel('Deine Idee')
+ .setPlaceholder('Was soll dazukommen oder anders laufen?')
+ .setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true)
+ ),
+ new ActionRowBuilder().addComponents(
+ new TextInputBuilder()
+ .setCustomId('warum').setLabel('Warum wäre das gut?')
+ .setPlaceholder('Was wird dadurch besser oder einfacher?')
+ .setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false)
+ )
+ );
+}
+
+/**
+ * Wunsch posten, speichern und den Thread aufmachen.
+ * @returns {Promise<{id: number, url: string}>}
+ */
+export async function wunschAnlegen(client, { idee, warum, autor, autorId, avatar, categoryId }) {
+ const channelId = votingChannelId();
+ if (!channelId) throw new Error('Feature-Voting ist gerade nicht aktiviert.');
+ const channel = await client.channels.fetch(channelId);
+ if (!channel?.isTextBased()) throw new Error('Voting-Kanal nicht gefunden.');
+
+ const bereich = categoryId ? getWishCategory(categoryId) : null;
+ const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch';
+ const embed = new EmbedBuilder()
+ .setColor(brandColor())
+ .setAuthor({ name: autor, iconURL: avatar || undefined })
+ .setTitle(kopf)
+ .setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee)
+ .setURL(`${publicUrl()}/roadmap`)
+ .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
+
+ const message = await channel.send({ embeds: [embed] });
+ await message.react('👍').catch(() => {});
+ const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null);
+
+ // Thread zum Reden. Scheitert er (fehlende Rechte, kein Thread-fähiger
+ // Kanal), bleibt der Wunsch trotzdem stehen — abstimmen geht auch ohne.
+ try {
+ const thread = await message.startThread({
+ name: `💬 ${idee}`.slice(0, 100),
+ autoArchiveDuration: tuning('thread_archive_days') * 1440,
+ });
+ setWishThread(id, thread.id);
+ } catch (error) {
+ console.error('[voting] Thread nicht angelegt:', error.message);
+ }
+ return { id, url: message.url };
+}
+
+/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */
+export async function handleWunschPick(interaction) {
+ const gewaehlt = Number(interaction.values?.[0]);
+ await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
+}
+
+/** Knopf ohne Bereiche → Eingabefenster ohne Bereich */
+export async function handleWunschButton(interaction) {
+ await interaction.showModal(wunschModal(null));
+}
+
+/** Formular abgeschickt → Wunsch anlegen */
+export async function handleWunschModal(interaction) {
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
+ const rohBereich = interaction.customId.split(':')[1];
+ const categoryId = rohBereich ? Number(rohBereich) : null;
+ const name = interaction.member?.displayName ?? interaction.user.username;
+ try {
+ const { url } = await wunschAnlegen(interaction.client, {
+ idee: interaction.fields.getTextInputValue('idee'),
+ warum: interaction.fields.getTextInputValue('warum')?.trim(),
+ autor: name,
+ autorId: interaction.user.id,
+ avatar: interaction.user.displayAvatarURL({ size: 64 }),
+ categoryId,
+ });
+ await interaction.editReply(renderTemplate('wish.submitted', {
+ user: name,
+ mention: `<@${interaction.user.id}>`,
+ url,
+ }));
+ } catch (error) {
+ await interaction.editReply(`❌ ${error.message}`).catch(() => {});
+ }
+}
+
+/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */
+export function registerWishThreads(client) {
+ client.on(Events.MessageCreate, (message) => {
+ try {
+ if (message.author?.bot || !message.channel?.isThread?.()) return;
+ bumpWishKommentare(message.channelId);
+ } catch (error) {
+ console.error('[voting] Kommentar-Zähler:', error);
+ }
+ });
+}
diff --git a/src/db.js b/src/db.js
index b1a9312..23dfc38 100644
--- a/src/db.js
+++ b/src/db.js
@@ -294,24 +294,80 @@ db.exec(`
}
}
+// Bereiche, in die ein Wunsch faellt — „Fahrzeuge", „Wirtschaft", „Technik".
+// Ohne Eintrag bleibt alles wie bisher: eine Liste ohne Schubladen.
+db.exec(`
+ CREATE TABLE IF NOT EXISTS wish_categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ emoji TEXT NOT NULL DEFAULT '',
+ sort INTEGER NOT NULL DEFAULT 0
+ );
+`);
+{
+ const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name);
+ if (!spalten.includes('category_id')) db.exec('ALTER TABLE wishes ADD COLUMN category_id INTEGER');
+ // Der Thread unter dem Wunsch-Post ist der Diskussionsort. Die Zahl der
+ // Beitraege darin zaehlen wir mit, statt sie fuer jede Seitenansicht bei
+ // Discord zu erfragen — 50 Threads je Aufruf waere nicht zu bezahlen.
+ if (!spalten.includes('thread_id')) db.exec('ALTER TABLE wishes ADD COLUMN thread_id TEXT');
+ if (!spalten.includes('kommentare')) {
+ db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0');
+ }
+}
+const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
+const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
+const insertWishCatStmt = db.prepare(
+ 'INSERT INTO wish_categories (name, emoji, sort) VALUES (@name, @emoji, @sort)'
+);
+const updateWishCatStmt = db.prepare(
+ 'UPDATE wish_categories SET name = @name, emoji = @emoji, sort = @sort WHERE id = @id'
+);
+const deleteWishCatStmt = db.prepare('DELETE FROM wish_categories WHERE id = ?');
+// Wuensche im geloeschten Bereich bleiben, sie stehen dann nur ohne Schild da
+const orphanWishesStmt = db.prepare('UPDATE wishes SET category_id = NULL WHERE category_id = ?');
+export const listWishCategories = () => listWishCatsStmt.all();
+export const getWishCategory = (id) => getWishCatStmt.get(id) ?? null;
+export const createWishCategory = (c) => insertWishCatStmt.run(c).lastInsertRowid;
+export const updateWishCategory = (c) => updateWishCatStmt.run(c);
+export const deleteWishCategory = db.transaction((id) => {
+ orphanWishesStmt.run(id);
+ return deleteWishCatStmt.run(id).changes > 0;
+});
+
/** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */
const WUNSCH_FELDER = `
- w.id, w.message_id, w.idea, w.author, w.author_id, w.status, w.status_grund,
- w.status_at, w.created_at,
- w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score
+ w.id, w.message_id, w.thread_id, w.idea, w.author, w.author_id, w.status,
+ w.status_grund, w.status_at, w.created_at, w.kommentare, w.category_id,
+ (SELECT k.name FROM wish_categories k WHERE k.id = w.category_id) AS kategorie,
+ (SELECT k.emoji FROM wish_categories k WHERE k.id = w.category_id) AS kategorie_emoji,
+ w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score,
+ -- Bewegung: Stimmen der letzten sieben Tage. Ein alter Wunsch mit vielen
+ -- Stimmen steht sonst fuer immer oben, egal ob noch jemand hinschaut.
+ (SELECT COUNT(*) FROM wish_votes v
+ WHERE v.wish_id = w.id AND v.created_at >= datetime('now', '-7 days')) AS bewegung
`;
const insertWish = db.prepare(
- 'INSERT INTO wishes (message_id, author, author_id, idea) VALUES (?, ?, ?, ?)'
+ 'INSERT INTO wishes (message_id, author, author_id, idea, category_id) VALUES (?, ?, ?, ?, ?)'
);
+const setWishThreadStmt = db.prepare('UPDATE wishes SET thread_id = ? WHERE id = ?');
+const bumpKommentareStmt = db.prepare(
+ 'UPDATE wishes SET kommentare = kommentare + 1 WHERE thread_id = ?'
+);
+const setWishCategoryStmt = db.prepare('UPDATE wishes SET category_id = ? WHERE id = ?');
const setWishMessageStmt = db.prepare('UPDATE wishes SET message_id = ? WHERE id = ?');
const wishByMessageStmt = db.prepare('SELECT * FROM wishes WHERE message_id = ?');
const wishByIdStmt = db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.id = ?`);
// Zusammengeführte Doppler tauchen nirgends mehr auf — ihre Stimmen stehen beim Original
-const topWishesStmt = db.prepare(`
- SELECT ${WUNSCH_FELDER} FROM wishes w
- WHERE w.merged_into IS NULL
- ORDER BY score DESC, w.created_at DESC LIMIT ?
-`);
+const SORTIERUNG = {
+ top: 'score DESC, w.created_at DESC',
+ bewegung: 'bewegung DESC, score DESC',
+ neu: 'w.created_at DESC',
+};
+const topWishesStmts = Object.fromEntries(Object.entries(SORTIERUNG).map(([k, ord]) => [
+ k,
+ db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL ORDER BY ${ord} LIMIT ?`),
+]));
const searchWishesStmt = db.prepare(`
SELECT ${WUNSCH_FELDER} FROM wishes w
WHERE w.merged_into IS NULL AND w.idea LIKE ?
@@ -327,12 +383,17 @@ const moveVotesStmt = db.prepare(
);
const dropVotesStmt = db.prepare('DELETE FROM wish_votes WHERE wish_id = ?');
-export const saveWish = (messageId, author, idea, authorId = null) =>
- insertWish.run(messageId, author, authorId, idea).lastInsertRowid;
+export const saveWish = (messageId, author, idea, authorId = null, categoryId = null) =>
+ insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid;
+export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id);
+export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id);
+/** @returns {boolean} true, wenn der Thread zu einem Wunsch gehoerte */
+export const bumpWishKommentare = (threadId) => bumpKommentareStmt.run(threadId).changes > 0;
export const setWishMessage = (id, messageId) => setWishMessageStmt.run(messageId, id);
export const wishByMessage = (messageId) => wishByMessageStmt.get(messageId) ?? null;
export const getWish = (id) => wishByIdStmt.get(id) ?? null;
-export const topWishes = (limit = 20) => topWishesStmt.all(limit);
+export const topWishes = (limit = 20, sortierung = 'top') =>
+ (topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit);
export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit);
export const deleteWish = (id) => {
dropVotesStmt.run(id);
diff --git a/src/templates.js b/src/templates.js
index 5de5b3d..dd734d5 100644
--- a/src/templates.js
+++ b/src/templates.js
@@ -190,6 +190,22 @@ export const TEMPLATES = [
vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!',
},
+ {
+ id: 'wish.panel_title', group: 'sonstiges', module: 'voting',
+ name: 'Ideen-Aufruf — Überschrift',
+ vars: [],
+ default: '💡 Deine Idee für EcoGame',
+ },
+ {
+ id: 'wish.panel_text', group: 'sonstiges', module: 'voting',
+ name: 'Ideen-Aufruf — Text', long: true,
+ vars: [],
+ default:
+ 'Dir fehlt etwas, oder du hättest gern, dass etwas anders läuft?\n\n' +
+ 'Such dir unten den passenden Bereich, schreib deine Idee auf — sie landet als '
+ + 'Post hier im Kanal. Mit 👍 stimmt die Community ab, und im Thread darunter '
+ + 'wird darüber geredet. Auf der Roadmap siehst du, was daraus geworden ist.',
+ },
{
id: 'bug.submitted', group: 'sonstiges', module: 'bug_reports',
name: 'Fehlermeldung — Bestätigung', long: true,
diff --git a/src/web/api.js b/src/web/api.js
index f016353..2af2d1a 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -15,7 +15,8 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
- getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters,
+ getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
+ listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
@@ -33,6 +34,7 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
+import { wunschAnlegen } from '../bot/wishes.js';
import { heartbeatSerie } from '../bot/heartbeat.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -186,8 +188,19 @@ export function registerApiRoutes(app, client) {
app.get('/api/wishes', async (request) => {
const user = getSessionUser(request);
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
+ const sortierung = String(request.query?.sort ?? 'top');
+ // Den Link zum Thread bauen wir hier — die Guild-ID gehört nicht auf
+ // die öffentliche Seite, nur der fertige Verweis
+ const gid = discordGuildId();
return {
- wishes: topWishes(50).map((w) => ({ ...w, voted: mine.has(w.id) })),
+ wishes: topWishes(50, sortierung).map((w) => ({
+ ...w,
+ voted: mine.has(w.id),
+ thread_url: gid && w.thread_id
+ ? `https://discord.com/channels/${gid}/${w.thread_id}`
+ : null,
+ })),
+ bereiche: listWishCategories(),
canVote: Boolean(user),
};
});
@@ -1925,31 +1938,26 @@ ${rssItems}
}
});
- // Wunsch einreichen (postet wie /wunsch in den Voting-Kanal)
+ // Wunsch einreichen — läuft durch denselben Weg wie /wunsch, damit ein
+ // Wunsch von hier genauso aussieht: Embed, 👍 und Thread zum Reden
app.post('/api/wishes', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
- const channelId = votingChannelId();
- if (!channelId) return reply.code(400).send({ error: 'Feature-Voting ist nicht aktiviert' });
- const channel = await client.channels.fetch(channelId).catch(() => null);
- if (!channel?.isTextBased()) return reply.code(500).send({ error: 'Voting-Kanal nicht gefunden' });
-
- const embed = new EmbedBuilder()
- .setColor(brandColor())
- .setAuthor({ name: user.username, iconURL: user.avatar ?? undefined })
- .setTitle('💡 Feature-Wunsch')
- .setDescription(idea)
- .setURL(`${publicUrl()}/roadmap`)
- .setFooter({
- text: `${brandFooter('VOTING')} • 👍 = will ich!`,
- iconURL: client.user?.displayAvatarURL?.({ size: 64 }),
+ const categoryId = Number(request.body?.category_id) || null;
+ try {
+ const { id } = await wunschAnlegen(client, {
+ idee: idea,
+ autor: user.username,
+ autorId: user.id,
+ avatar: user.avatar ?? null,
+ categoryId,
});
- const message = await channel.send({ embeds: [embed] });
- await message.react('👍').catch(() => {});
- const id = saveWish(message.id, user.username, idea, user.id);
- return { ok: true, id };
+ return { ok: true, id };
+ } catch (error) {
+ return reply.code(400).send({ error: error.message });
+ }
});
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
@@ -1969,6 +1977,52 @@ ${rssItems}
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
});
+ // Bereiche, in die ein Wunsch faellt. Ohne Eintrag bleibt die Liste eine
+ // Liste — Schubladen lohnen sich erst ab einer gewissen Zahl.
+ app.get('/api/wish-categories', async (request, reply) => {
+ if (requireAnyScope(request, reply)) return;
+ return { bereiche: listWishCategories() };
+ });
+
+ function bereichBody(request, reply) {
+ const name = String(request.body?.name ?? '').trim();
+ if (!name) {
+ reply.code(400).send({ error: 'Name fehlt.' });
+ return null;
+ }
+ return {
+ name: name.slice(0, 40),
+ emoji: String(request.body?.emoji ?? '').trim().slice(0, 32),
+ sort: Number(request.body?.sort) || 0,
+ };
+ }
+
+ app.post('/api/wish-categories', async (request, reply) => {
+ if (requireScope(request, reply, 'community')) return;
+ const b = bereichBody(request, reply);
+ if (!b) return;
+ createWishCategory(b);
+ logAudit(getSessionUser(request), 'wunsch-bereich angelegt', b.name);
+ return { ok: true, bereiche: listWishCategories() };
+ });
+
+ app.put('/api/wish-categories/:id', async (request, reply) => {
+ if (requireScope(request, reply, 'community')) return;
+ const b = bereichBody(request, reply);
+ if (!b) return;
+ updateWishCategory({ ...b, id: Number(request.params.id) });
+ logAudit(getSessionUser(request), 'wunsch-bereich geaendert', b.name);
+ return { ok: true, bereiche: listWishCategories() };
+ });
+
+ // Wuensche im geloeschten Bereich bleiben stehen, nur ohne Schild
+ app.delete('/api/wish-categories/:id', async (request, reply) => {
+ if (requireScope(request, reply, 'community')) return;
+ const weg = deleteWishCategory(Number(request.params.id));
+ logAudit(getSessionUser(request), 'wunsch-bereich entfernt', String(request.params.id));
+ return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
+ });
+
// --- Wünsche verwalten (Team) ---
const STATUS = {
@@ -2034,6 +2088,14 @@ ${rssItems}
return { ok: true, benachrichtigt, wishes: topWishes(50) };
});
+ app.put('/api/wishes/:id/category', async (request, reply) => {
+ if (requireScope(request, reply, 'community')) return;
+ const id = Number(request.params.id);
+ if (!getWish(id)) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
+ setWishCategory(id, Number(request.body?.category_id) || null);
+ return { ok: true, wishes: topWishes(50) };
+ });
+
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
// gestimmt hat, zählt dort weiterhin einmal
app.post('/api/wishes/:id/merge', async (request, reply) => {