diff --git a/README.md b/README.md index 3bc2f66..171c3b7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 📈 **Level-System** | XP pro Nachricht (Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste | | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen im Best-of-Kanal | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) | -| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster → Voting-Post mit 👍; Wünsche mit **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung auf der Roadmap und im Discord-Post. Wer für einen Wunsch gestimmt hat, bekommt eine DM, sobald er umgesetzt ist. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | +| 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | | 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | @@ -454,6 +454,7 @@ urllib.request.urlopen(req) | `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung | | `GET /api/profile` · `/api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten | | `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen | +| `GET/POST/PUT/DELETE /api/wish-categories` | Team (community) | Bereiche für Wünsche pflegen | | `GET /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles | | `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 03b60fa..4edaee6 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -120,6 +120,13 @@ export default { 'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten', 'roadmap.commitsOn': '%s: %s Commits', 'roadmap.commitOn': '%s: 1 Commit', + 'roadmap.sort': 'Sortierung', + 'roadmap.sortTop': 'Top', + 'roadmap.sortTrend': 'Bewegung', + 'roadmap.sortNew': 'Neu', + 'roadmap.status': 'Stand', + 'roadmap.area': 'Bereich', + 'roadmap.areaPick': '— Bereich —', 'wish.alle': 'Alle', 'wish.offen': 'Wird geprüft', 'wish.geplant': 'Geplant', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index c34fb58..b1d5eb1 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -119,6 +119,13 @@ export default { 'roadmap.activity': '// Activity — %s commits in 12 months', 'roadmap.commitsOn': '%s: %s commits', 'roadmap.commitOn': '%s: 1 commit', + 'roadmap.sort': 'Sort', + 'roadmap.sortTop': 'Top', + 'roadmap.sortTrend': 'Trending', + 'roadmap.sortNew': 'New', + 'roadmap.status': 'Status', + 'roadmap.area': 'Area', + 'roadmap.areaPick': '— Area —', 'wish.alle': 'All', 'wish.offen': 'Under review', 'wish.geplant': 'Planned', diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index 7073856..69970d1 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; -import { IconThumbsUp } from '../icons.jsx'; +import { IconThumbsUp, IconMessage } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; const ZIEL = { month: 'long', year: 'numeric' }; @@ -27,6 +27,11 @@ const WISH_STATUS = { umgesetzt: 'wish.umgesetzt', abgelehnt: 'wish.abgelehnt', }; +const WISH_SORT = [ + { id: 'top', key: 'roadmap.sortTop' }, + { id: 'bewegung', key: 'roadmap.sortTrend' }, + { id: 'neu', key: 'roadmap.sortNew' }, +]; const WISH_FILTER = [ { id: 'alle', key: 'wish.alle' }, { id: 'offen', key: 'wish.offen' }, @@ -113,15 +118,30 @@ export default function Roadmap() { const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); const [filter, setFilter] = useState('alle'); + const [bereich, setBereich] = useState('alle'); + const [bereiche, setBereiche] = useState([]); + const [neuerBereich, setNeuerBereich] = useState(''); + const [sort, setSort] = useState('top'); const [aehnliche, setAehnliche] = useState([]); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); - apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {}); apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {}); window.scrollTo(0, 0); }, []); + // Sortiert wird auf dem Server — „Bewegung" braucht die Zeitstempel der + // Stimmen, die hier gar nicht ankommen + useEffect(() => { + apiGet(`/api/wishes?sort=${sort}`) + .then((d) => { + setWishes(d.wishes); + setCanVote(d.canVote); + setBereiche(d.bereiche ?? []); + }) + .catch(() => {}); + }, [sort]); + // Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten, // damit nicht jeder Tastendruck eine Anfrage auslöst. useEffect(() => { @@ -135,9 +155,9 @@ export default function Roadmap() { return () => clearTimeout(timer); }, [idea]); - const sichtbareWuensche = filter === 'alle' - ? wishes - : wishes.filter((w) => (w.status ?? 'offen') === filter); + const sichtbareWuensche = wishes + .filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter) + .filter((w) => bereich === 'alle' || w.category_id === bereich); async function vote(id) { try { @@ -155,10 +175,13 @@ export default function Roadmap() { const text = idea.trim(); if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; } try { - await apiPost('/api/wishes', { idea: text }); + await apiPost('/api/wishes', { + idea: text, + category_id: neuerBereich ? Number(neuerBereich) : null, + }); setIdea(''); setSubmitMsg(t('roadmap.submitted')); - apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {}); + apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {}); } catch (e) { setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed')); } @@ -189,25 +212,62 @@ export default function Roadmap() {

{t('roadmap.wishes')}

- {/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den - niemand mehr einreicht — deshalb steht der Stand vorne - dran und lässt sich filtern. */} -
- {WISH_FILTER.map(({ id, key }) => { - const n = id === 'alle' - ? wishes.length - : wishes.filter((w) => (w.status ?? 'offen') === id).length; - if (n === 0 && id !== 'alle') return null; - return ( +
+ {/* Sortierung: die Rangliste allein zementiert alte + Wünsche — „Bewegung" zeigt, wo diese Woche etwas + passiert, „Neu" das Frischeste. */} +
+ {t('roadmap.sort')} + {WISH_SORT.map(({ id, key }) => ( - ); - })} + ))} +
+ +
+ {t('roadmap.status')} + {WISH_FILTER.map(({ id, key }) => { + const n = id === 'alle' + ? wishes.length + : wishes.filter((w) => (w.status ?? 'offen') === id).length; + if (n === 0 && id !== 'alle') return null; + return ( + + ); + })} +
+ + {bereiche.length > 0 && ( +
+ {t('roadmap.area')} + + {bereiche.map((b) => ( + + ))} +
+ )}
{sichtbareWuensche.length === 0 && ( @@ -230,13 +290,27 @@ export default function Roadmap() { {w.score} )} + + {status !== 'offen' && ( + {t(WISH_STATUS[status])} + )} + {w.kategorie && ( + + {w.kategorie_emoji} {w.kategorie} + + )} + {w.idea} {w.status_grund && ( {w.status_grund} )} - {status !== 'offen' && ( - {t(WISH_STATUS[status])} + {/* Geredet wird im Thread unter dem Post — der + Zähler führt direkt dorthin */} + {w.thread_url && ( + + {w.kommentare ?? 0} + )} {w.author}
@@ -246,6 +320,20 @@ export default function Roadmap() { {canVote ? (
+ {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 }) { )} +
+

// Wunsch-Bereiche

+

+ Schubladen für Ideen — „Fahrzeuge", „Wirtschaft", „Technik". Ohne Bereiche + bleibt die Liste eine Liste; mit ihnen kann man auf der Roadmap filtern, und + der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem + Ändern /wunsch-setup neu ausführen. +

+ + {bereiche.length === 0 &&

Noch keine Bereiche.

} + + {bereiche.length > 0 && ( +
+ {bereiche.map((b) => ( +
+ {b.emoji} + {b.name} + + {(() => { + const n = wuensche.filter((w) => w.category_id === b.id).length; + return `${n} ${n === 1 ? 'Wunsch' : 'Wünsche'}`; + })()} + + + +
+ ))} +
+ )} + +
+ setBereichDraft({ ...bereichDraft, emoji: e.target.value })} + /> + setBereichDraft({ ...bereichDraft, name: e.target.value })} + onKeyDown={(e) => { if (e.key === 'Enter') bereichSpeichern(); }} + /> + + {bereichDraft.id && ( + + )} +
+
+

// 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) => {