From cc7a51f02ed1d7066e6f8b85cbbe8965a253091e Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 23:45:18 +0200 Subject: [PATCH] Emojis zum Anklicken, und Wuensche als Forums-Beitraege mit Tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ueberall, wo ein Emoji nach Discord geht — Rollen-Menues, Ticket-Anliegen, Wunsch-Bereiche, Portal-Kacheln — stand ein leeres Textfeld, in das man mit Win+. hineintippen musste. Jetzt sitzt daneben ein Knopf mit einer Tafel: sieben Gruppen, deutsche Suchbegriffe ("auto", "geld", "warnung"), ein Klick schreibt ins Feld. Das Feld bleibt daneben stehen, denn Server-Emojis (<:name:123>) kennt nur Discord. Bewusst eine eigene Liste statt einer Bibliothek: die vollstaendige Unicode-Tabelle waere ein halbes Megabyte fuer eine Handvoll Symbole, und die Suchbegriffe duerfen deutsch sein. ## Forum-Tags Ist der Voting-Kanal ein Forum, wird jeder Wunsch jetzt ein Forums-Beitrag statt Nachricht-plus-Thread — und bekommt Tags fuer Bereich und Stand. Damit laesst sich auch in Discord filtern, wofuer Tags ja da sind. Beim Wechsel des Stands zieht das Tag mit: der alte fliegt raus, der neue kommt rein, der Bereich bleibt stehen. Zugeordnet wird ueber den Namen. Wer im Forum ein Tag "Fahrzeuge" anlegt, bekommt es automatisch gesetzt; wer keines anlegt, verliert nichts. Bewusst keine ID-Zuordnung im Panel — das waere eine zweite Liste, die man pflegen muss und die beim ersten Umbenennen auseinanderlaeuft. Dabei aufgefallen und mitgefixt: im Forum liegt die Startnachricht im Beitrag selbst und nicht im Kanal. Der Status-Aktualisierer hat sie vorher im Kanal gesucht und nicht gefunden — die Begruendung waere im Forum also nie im Post gelandet. Und der Titel wird jetzt nur um den Stand ergaenzt statt ueberschrieben, damit "🚗 Fahrzeuge" stehen bleibt. Geprueft: tagsFuer gegen 20 Faelle — kein Forum, Medien-Kanal, fehlende Tags, Gross/Kleinschreibung, Deckel bei fuenf (mehr nimmt Discord nicht), und alle fuenf Staende einzeln. Im Browser die Emoji-Tafel an allen vier Stellen: Suche, Escape, Klick daneben, Leeren, Uebernehmen. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- frontend/src/components/EmojiPick.jsx | 167 ++++++++++++++++++++++++++ frontend/src/pages/Settings.jsx | 34 ++++-- frontend/src/style.css | 49 ++++++++ src/bot/commands/wunsch-setup.js | 10 ++ src/bot/wishes.js | 88 +++++++++++++- src/web/api.js | 42 ++++--- 7 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/EmojiPick.jsx diff --git a/README.md b/README.md index 171c3b7..655caf3 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-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 | +| 💡 **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. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. 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 | diff --git a/frontend/src/components/EmojiPick.jsx b/frontend/src/components/EmojiPick.jsx new file mode 100644 index 0000000..187930a --- /dev/null +++ b/frontend/src/components/EmojiPick.jsx @@ -0,0 +1,167 @@ +// Emoji-Auswahl fürs Panel. +// +// Überall, wo ein Emoji nach Discord geht — Rollen-Menüs, Ticket-Anliegen, +// Wunsch-Bereiche, Portal-Kacheln — stand bisher ein Textfeld, in das man mit +// Win+. hineintippen musste. Das hier ist derselbe Wert, nur klickbar. +// +// Bewusst eine eigene Liste statt einer Bibliothek: die vollständige +// Unicode-Tabelle wäre ein halbes Megabyte für eine Handvoll Symbole, und die +// Suchbegriffe dürfen deutsch sein. +// +// Server-Emojis (<:name:id>) tippt man weiter ins Feld — die kennt nur Discord. +import { useEffect, useRef, useState } from 'react'; +import { IconSearch, IconX } from '../icons.jsx'; + +const GRUPPEN = [ + ['Häufig', '⭐', [ + ['💡', 'idee gluehbirne wunsch'], ['🎫', 'ticket'], ['🐛', 'bug fehler kaefer'], + ['🎮', 'spiel gaming controller'], ['🎨', 'design farbe kunst'], ['🔧', 'technik werkzeug'], + ['📢', 'ankuendigung megafon'], ['💬', 'chat reden nachricht'], ['⭐', 'stern favorit'], + ['✅', 'haken fertig ja'], ['❌', 'kreuz nein fehler'], ['⚠️', 'warnung achtung'], + ['🔒', 'schloss privat gesperrt'], ['🚀', 'rakete release start'], ['🔥', 'feuer heiss'], + ['❤️', 'herz liebe'], ['👍', 'daumen gut ja'], ['🎉', 'party feier'], + ]], + ['Spiel', '🎮', [ + ['🚗', 'auto fahrzeug'], ['🏎️', 'rennwagen'], ['🚓', 'polizei streifenwagen'], + ['🚑', 'krankenwagen rettung'], ['🚒', 'feuerwehr'], ['🏍️', 'motorrad'], + ['🚁', 'hubschrauber heli'], ['✈️', 'flugzeug'], ['🚤', 'boot schiff'], + ['⛵', 'segelboot'], ['🗺️', 'karte map'], ['🧭', 'kompass navigation'], + ['⚔️', 'kampf waffen pvp'], ['🛡️', 'schild verteidigung'], ['🏹', 'bogen jagd'], + ['💣', 'bombe'], ['🎯', 'ziel treffer'], ['🏆', 'pokal gewinner'], + ['🥇', 'gold erster'], ['🎲', 'wuerfel zufall'], ['🕹️', 'joystick arcade'], + ['🧪', 'test playtester labor'], ['⚙️', 'zahnrad einstellung'], ['🔫', 'waffe'], + ]], + ['Wirtschaft', '💰', [ + ['💰', 'geld sack wirtschaft'], ['💵', 'schein dollar'], ['💳', 'karte bezahlen'], + ['🏦', 'bank'], ['📈', 'steigend gewinn'], ['📉', 'fallend verlust'], + ['🛒', 'einkauf shop'], ['🏪', 'laden geschaeft'], ['📦', 'paket lieferung'], + ['🏭', 'fabrik produktion'], ['⛏️', 'spitzhacke abbau'], ['🌾', 'landwirtschaft ernte'], + ['🔨', 'hammer bauen'], ['🧰', 'werkzeugkasten'], ['💎', 'diamant edelstein'], + ['🪙', 'muenze'], + ]], + ['Orte', '🏠', [ + ['🏠', 'haus zuhause'], ['🏢', 'buero gebaeude'], ['🏥', 'krankenhaus'], + ['🏫', 'schule'], ['🏛️', 'amt behoerde'], ['⛽', 'tankstelle'], + ['🌳', 'baum natur'], ['🏝️', 'insel'], ['🏔️', 'berg'], + ['🌊', 'welle wasser meer'], ['🌆', 'stadt'], ['🛣️', 'strasse'], + ['🚧', 'baustelle'], ['🎪', 'zelt event'], ['🎬', 'film kino'], + ['🍔', 'essen restaurant'], ['☕', 'kaffee'], ['🍺', 'bier bar'], + ]], + ['Team', '👥', [ + ['👥', 'team gruppe'], ['👤', 'person nutzer'], ['👋', 'winken willkommen'], + ['🙋', 'melden frage'], ['👮', 'polizist'], ['🧑‍⚕️', 'arzt sanitaeter'], + ['🧑‍🔧', 'mechaniker'], ['🧑‍🌾', 'bauer'], ['🧑‍💻', 'entwickler'], + ['🎭', 'rollen maske'], ['🤝', 'handschlag zusammen'], ['🗣️', 'sprechen stimme'], + ['📣', 'ruf laut'], ['🎤', 'mikrofon stream'], ['🎧', 'kopfhoerer voice'], + ]], + ['Technik', '🖥️', [ + ['🖥️', 'rechner server'], ['💾', 'diskette speichern backup'], ['🗄️', 'archiv ablage'], + ['📡', 'antenne verbindung'], ['🔌', 'stecker strom'], ['🔋', 'akku batterie'], + ['📊', 'diagramm statistik'], ['📋', 'liste protokoll'], ['📝', 'notiz schreiben'], + ['📅', 'kalender termin'], ['⏰', 'wecker zeit'], ['⏳', 'sanduhr warten'], + ['🔍', 'lupe suche'], ['🔗', 'link verbindung'], ['🏷️', 'schild tag'], + ['🗂️', 'ordner dienst'], ['📁', 'ordner'], ['🧹', 'besen aufraeumen'], + ]], + ['Zeichen', '🔵', [ + ['🟢', 'gruen online ok'], ['🟡', 'gelb warnung'], ['🔴', 'rot aus fehler'], + ['🔵', 'blau'], ['🟣', 'lila'], ['⚫', 'schwarz'], + ['⚪', 'weiss'], ['➕', 'plus hinzufuegen'], ['➖', 'minus entfernen'], + ['❓', 'frage hilfe'], ['❗', 'ausruf wichtig'], ['🔔', 'glocke benachrichtigung'], + ['🔕', 'stumm'], ['🚫', 'verboten nein'], ['♻️', 'recycling wiederholen'], + ['🆕', 'neu'], ['🆓', 'gratis frei'], ['🔝', 'oben top'], + ]], +]; + +export default function EmojiPick({ value, onChange, placeholder = '🙂' }) { + const [offen, setOffen] = useState(false); + const [suche, setSuche] = useState(''); + const wrap = useRef(null); + + // Klick daneben oder Escape schließt — ohne das bleibt das Feld offen + // stehen, sobald man woanders weiterarbeitet + useEffect(() => { + if (!offen) return undefined; + const aus = (e) => { if (!wrap.current?.contains(e.target)) setOffen(false); }; + const taste = (e) => { if (e.key === 'Escape') setOffen(false); }; + document.addEventListener('mousedown', aus); + document.addEventListener('keydown', taste); + return () => { + document.removeEventListener('mousedown', aus); + document.removeEventListener('keydown', taste); + }; + }, [offen]); + + const q = suche.trim().toLowerCase(); + const gefiltert = GRUPPEN + .map(([name, symbol, liste]) => [ + name, + symbol, + q ? liste.filter(([e, worte]) => worte.includes(q) || e === q) : liste, + ]) + .filter(([, , liste]) => liste.length > 0); + + return ( +
+
+ + {/* Das Feld bleibt: Server-Emojis wie <:name:123> gibt es hier nicht */} + onChange(e.target.value)} + /> + {value && ( + + )} +
+ + {offen && ( +
+
+ + setSuche(e.target.value)} + autoFocus + /> +
+
+ {gefiltert.length === 0 &&

Nichts gefunden.

} + {gefiltert.map(([name, symbol, liste]) => ( +
+
{symbol} {name}
+
+ {liste.map(([e, worte]) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 321b66f..0e65e4a 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -12,6 +12,7 @@ import { moduleIcon } from '../module-icons.jsx'; import ModulePage from '../components/ModulePage.jsx'; import UptimeTrack from '../components/UptimeTrack.jsx'; import Dialog from '../components/Dialog.jsx'; +import EmojiPick from '../components/EmojiPick.jsx'; import { findSettings, SETTING_INDEX } from '../setting-index.js'; import { Markdown } from '../markdown.jsx'; @@ -1878,6 +1879,14 @@ export default function Settings({ me }) { der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem Ändern /wunsch-setup neu ausführen.

+

+ Ist der Voting-Kanal ein Forum, wird jeder Wunsch ein + Forums-Beitrag und bekommt Tags. Leg dort Tags mit denselben Namen an wie hier + die Bereiche, dazu Wird geprüft, Geplant, In Arbeit, + Umgesetzt und Nicht geplant — dann kann man auch in Discord + danach filtern, und der Stand zieht automatisch mit. Fehlt ein Tag, bleibt es + einfach ohne. +

{bereiche.length === 0 &&

Noch keine Bereiche.

} @@ -1906,10 +1915,10 @@ export default function Settings({ me }) { )}
- setBereichDraft({ ...bereichDraft, emoji: e.target.value })} + onChange={(v) => setBereichDraft({ ...bereichDraft, emoji: v })} + placeholder="🚗" /> Rollen Emoji optional (auch Custom als <:name:id>), Label = Button-Text {menuDraft.entries.map((entry, i) => (
- setMenuEntry(i, { emoji: e.target.value })} /> + setMenuEntry(i, { emoji: v })} + placeholder="🎮" + /> setMenuEntry(i, { label: e.target.value })} />
- setAnliegenDraft({ ...anliegenDraft, emoji: e.target.value })} /> + setAnliegenDraft({ ...anliegenDraft, emoji: v })} + placeholder="🐛" + />
@@ -2896,7 +2912,11 @@ export default function Settings({ me }) {
- setServiceDraft({ ...serviceDraft, icon: e.target.value })} /> + setServiceDraft({ ...serviceDraft, icon: v })} + placeholder="🗂️" + /> setServiceDraft({ ...serviceDraft, name: e.target.value })} /> setServiceDraft({ ...serviceDraft, url: e.target.value })} /> setServiceDraft({ ...serviceDraft, sort: e.target.value })} /> diff --git a/frontend/src/style.css b/frontend/src/style.css index 5a5e57c..bccd794 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1963,6 +1963,55 @@ label.toggle { border-left: 2px solid var(--border); padding-left: .6rem; } +/* ── EMOJI-AUSWAHL ─────────────────────────────────── */ +.ep-wrap { position: relative; display: inline-block; min-width: 0; } +.ep-feld { display: flex; align-items: stretch; gap: .3rem; } +.ep-knopf { + flex: 0 0 auto; width: 2.4rem; + background: var(--bg3); border: 1px solid var(--border); color: var(--text); + font-size: 1.05rem; line-height: 1; cursor: pointer; + display: grid; place-items: center; +} +.ep-knopf:hover { border-color: var(--neon); } +.ep-leer { opacity: .35; } +.ep-eingabe { width: 5.5rem; } +.ep-weg { + flex: 0 0 auto; background: none; border: none; color: var(--muted2); + cursor: pointer; padding: 0 .2rem; +} +.ep-weg:hover { color: #f23f43; } + +.ep-tafel { + position: absolute; z-index: 60; top: calc(100% + .35rem); left: 0; + width: min(21rem, 78vw); + background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; + box-shadow: 0 12px 30px rgba(0, 0, 0, .55); + animation: riseIn .15s ease both; +} +.ep-suche { + display: flex; align-items: center; gap: .45rem; + padding: .5rem .7rem; border-bottom: 1px solid var(--border); color: var(--muted2); +} +.ep-suche input { + flex: 1; min-width: 0; background: none; border: none; padding: 0; + color: var(--text); font: inherit; font-size: .84rem; +} +.ep-suche input:focus { outline: none; } +.ep-liste { max-height: 17rem; overflow-y: auto; padding: .5rem .7rem .7rem; } +.ep-gruppe { + font-family: var(--mono); font-size: .58rem; letter-spacing: .12em; + text-transform: uppercase; color: var(--muted2); margin: .5rem 0 .3rem; +} +.ep-raster { display: grid; grid-template-columns: repeat(auto-fill, 2rem); gap: .15rem; } +.ep-zelle { + width: 2rem; height: 2rem; display: grid; place-items: center; + background: none; border: 1px solid transparent; border-radius: 4px; + font-size: 1.05rem; line-height: 1; cursor: pointer; +} +.ep-zelle:hover { background: var(--bg3); border-color: var(--neon); } +.ep-zelle.aktiv { border-color: var(--neon); background: color-mix(in srgb, var(--neon) 12%, transparent); } +.ep-nix { color: var(--muted); font-size: .84rem; margin: .6rem 0; } + /* ── DIALOG ────────────────────────────────────────── */ .dlg-backdrop { position: fixed; inset: 0; z-index: 1200; diff --git a/src/bot/commands/wunsch-setup.js b/src/bot/commands/wunsch-setup.js index 96d3041..0b19677 100644 --- a/src/bot/commands/wunsch-setup.js +++ b/src/bot/commands/wunsch-setup.js @@ -29,6 +29,16 @@ export async function execute(interaction) { .setFooter({ text: brandFooter('VOTING') }); const anzahl = listWishCategories().length; + // In einem Forum-Kanal selbst kann man nichts posten — der Aufruf gehört + // in einen normalen Kanal, die Wünsche landen trotzdem im Forum + if (!interaction.channel?.send) { + await interaction.reply({ + content: '❌ Hier lässt sich nichts posten. Führ den Befehl in einem normalen Textkanal aus — ' + + 'die Ideen landen trotzdem im eingestellten Voting-Kanal.', + flags: MessageFlags.Ephemeral, + }); + return; + } await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() }); await interaction.reply({ content: anzahl > 0 diff --git a/src/bot/wishes.js b/src/bot/wishes.js index d0a6e25..ae7703e 100644 --- a/src/bot/wishes.js +++ b/src/bot/wishes.js @@ -10,7 +10,7 @@ // Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und // verlinkt hinein. import { - ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events, + ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, Events, ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js'; import { @@ -21,6 +21,22 @@ import { renderTemplate } from '../templates.js'; import { tuning } from '../tuning.js'; import { alsEmoji } from './tickets.js'; +/** + * Der Stand eines Wunsches, wie er nach außen heißt. Steht hier und nicht in + * der API, weil ihn beides braucht: das Panel zum Anzeigen und der Bot für + * Embed-Titel und Forum-Tags. + */ +export const STATUS_LABEL = { + offen: 'Wird geprüft', + geplant: 'Geplant', + in_arbeit: 'In Arbeit', + umgesetzt: 'Umgesetzt', + abgelehnt: 'Nicht geplant', +}; +export const STATUS_EMOJI = { + offen: '💡', geplant: '📌', in_arbeit: '🔨', umgesetzt: '✅', abgelehnt: '🚫', +}; + /** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */ export function wunschPanelComponents() { const bereiche = listWishCategories(); @@ -67,15 +83,41 @@ export function wunschModal(categoryId = null) { ); } +/** Ist der Voting-Kanal ein Forum? Dann wird jeder Wunsch ein Forums-Beitrag. */ +const istForum = (channel) => + channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia; + +/** + * Passende Forum-Tags zu Bereich und Stand heraussuchen — über den Namen. + * Wer im Forum ein Tag „Fahrzeuge" anlegt, bekommt es automatisch gesetzt; + * wer keines anlegt, verliert nichts. Deshalb bewusst keine ID-Zuordnung im + * Panel: das wäre eine zweite Liste, die man pflegen muss. + */ +export function tagsFuer(channel, { bereich, statusLabel }) { + if (!istForum(channel)) return []; + const gesucht = [bereich?.name, statusLabel].filter(Boolean).map((s) => s.toLowerCase()); + return (channel.availableTags ?? []) + .filter((t) => gesucht.includes(t.name.toLowerCase())) + .map((t) => t.id) + .slice(0, 5); // Discord erlaubt höchstens fünf je Beitrag +} + /** * Wunsch posten, speichern und den Thread aufmachen. + * + * Im Forum ist der Beitrag selbst schon der Thread — dort wird er mit Tags für + * Bereich und Stand angelegt, damit man auch in Discord filtern kann. In einem + * normalen Textkanal bleibt es bei Nachricht + Thread darunter. + * * @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.'); + if (!channel?.isTextBased() && !istForum(channel)) { + throw new Error('Voting-Kanal nicht gefunden.'); + } const bereich = categoryId ? getWishCategory(categoryId) : null; const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch'; @@ -87,6 +129,22 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata .setURL(`${publicUrl()}/roadmap`) .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` }); + const archiv = tuning('thread_archive_days') * 1440; + + if (istForum(channel)) { + const thread = await channel.threads.create({ + name: idee.slice(0, 100), + autoArchiveDuration: archiv, + appliedTags: tagsFuer(channel, { bereich, statusLabel: STATUS_LABEL.offen }), + message: { embeds: [embed] }, + }); + const starter = await thread.fetchStarterMessage().catch(() => null); + await starter?.react('👍').catch(() => {}); + const id = saveWish(starter?.id ?? thread.id, autor, idee, autorId ?? null, categoryId ?? null); + setWishThread(id, thread.id); + return { id, url: `${thread.url}` }; + } + const message = await channel.send({ embeds: [embed] }); await message.react('👍').catch(() => {}); const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null); @@ -96,7 +154,7 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata try { const thread = await message.startThread({ name: `💬 ${idee}`.slice(0, 100), - autoArchiveDuration: tuning('thread_archive_days') * 1440, + autoArchiveDuration: archiv, }); setWishThread(id, thread.id); } catch (error) { @@ -105,6 +163,30 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata return { id, url: message.url }; } +/** + * Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte + * Stand fliegt raus, der neue kommt rein, der Bereich bleibt. + */ +export async function wunschTagsAktualisieren(client, wunsch, statusLabel) { + if (!wunsch.thread_id) return; + const channelId = votingChannelId(); + if (!channelId) return; + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!istForum(channel)) return; + const thread = await client.channels.fetch(wunsch.thread_id).catch(() => null); + if (!thread?.setAppliedTags) return; + + const alleStaende = new Set(Object.values(STATUS_LABEL).map((s) => s.toLowerCase())); + const behalten = (thread.appliedTags ?? []).filter((id) => { + const tag = (channel.availableTags ?? []).find((t) => t.id === id); + return tag && !alleStaende.has(tag.name.toLowerCase()); + }); + const neu = tagsFuer(channel, { bereich: null, statusLabel }); + await thread.setAppliedTags([...new Set([...behalten, ...neu])].slice(0, 5)).catch((e) => + console.error('[voting] Tags nicht gesetzt:', e.message) + ); +} + /** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */ export async function handleWunschPick(interaction) { const gewaehlt = Number(interaction.values?.[0]); diff --git a/src/web/api.js b/src/web/api.js index 2af2d1a..37a9684 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -34,7 +34,9 @@ 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 { + wunschAnlegen, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI, +} 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'; @@ -2025,13 +2027,9 @@ ${rssItems} // --- Wünsche verwalten (Team) --- - const STATUS = { - offen: { label: 'Wird geprüft', emoji: '💡' }, - geplant: { label: 'Geplant', emoji: '📌' }, - in_arbeit: { label: 'In Arbeit', emoji: '🔨' }, - umgesetzt: { label: 'Umgesetzt', emoji: '✅' }, - abgelehnt: { label: 'Nicht geplant', emoji: '🚫' }, - }; + const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [ + id, { label, emoji: STATUS_EMOJI[id] }, + ])); /** * Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die @@ -2042,16 +2040,29 @@ ${rssItems} if (!wunsch.message_id) return; const channelId = votingChannelId(); if (!channelId) return; - const channel = await client.channels.fetch(channelId).catch(() => null); - const message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null); + // Im Forum liegt die Startnachricht im Beitrag selbst, nicht im Kanal — + // deshalb zuerst dort suchen, wo der Wunsch seinen Thread hat + const wo = wunsch.thread_id + ? await client.channels.fetch(wunsch.thread_id).catch(() => null) + : null; + const channel = wo ?? await client.channels.fetch(channelId).catch(() => null); + let message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null); + if (!message && wo) { + // Textkanal mit Thread darunter: die Nachricht steht eine Ebene höher + const eltern = await client.channels.fetch(channelId).catch(() => null); + message = await eltern?.messages?.fetch(wunsch.message_id).catch(() => null); + } if (!message?.embeds?.[0]) return; + const s = STATUS[wunsch.status] ?? STATUS.offen; - const embed = EmbedBuilder.from(message.embeds[0]) - .setTitle(`${s.emoji} Feature-Wunsch — ${s.label}`); - const felder = wunsch.status_grund + const alt = message.embeds[0]; + // Den Kopf nur um den Stand ergänzen, nicht den Bereich überschreiben — + // dort steht „🚗 Fahrzeuge", das soll bleiben + const basis = String(alt.title ?? 'Feature-Wunsch').split(' — ')[0]; + const embed = EmbedBuilder.from(alt).setTitle(`${basis} — ${s.emoji} ${s.label}`); + embed.setFields(wunsch.status_grund ? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }] - : []; - embed.setFields(felder); + : []); await message.edit({ embeds: [embed] }).catch(() => {}); } @@ -2066,6 +2077,7 @@ ${rssItems} const wunsch = getWish(id); await wunschPostAktualisieren(wunsch); + await wunschTagsAktualisieren(client, wunsch, STATUS[status].label); // Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim // Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)