From 5073e7bcf497ffbcd3dfd980875c80aaff8eadd3 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sun, 2 Aug 2026 00:05:27 +0200 Subject: [PATCH] Jede Idee bekommt ihre eigene Seite mit der Unterhaltung aus dem Thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorbild windrose.support: Klick auf eine Idee, und man sieht sie ganz — Stand, Begruendung, wer sie eingereicht hat, und darunter, was dazu geschrieben wurde. Der Unterschied bleibt, wo geschrieben wird. Die Beitraege sind aus dem Discord-Thread gespiegelt, nicht hier getippt. Deshalb fuehrt der einzige Knopf am Ende in den Thread und nicht in ein Formular. Ein zweiter Ort fuer dieselbe Unterhaltung waere genau das, was ich beim Konzept als das Falsche bezeichnet habe. Bisher wurde nur mitgezaehlt. Jetzt landen die Beitraege in einer eigenen Tabelle — mit Bearbeiten und Loeschen, sonst stuende auf der Webseite fuer immer, was in Discord laengst zurueckgenommen wurde. Reine Bild-Posts ohne Text bleiben draussen, die haetten hier nichts zu sagen. Der Kommentar-Zaehler wird jetzt aus der Tabelle gezaehlt statt in einer Spalte mitgefuehrt. Eine Wahrheit statt zwei, die auseinanderlaufen koennen — dieselbe Ueberlegung wie beim Zuspruch. Nebenbei geprueft, weil es haette schiefgehen koennen: /api/wishes/suche und /api/wishes/:id liegen auf derselben Ebene. Der Router bevorzugt die feste Route vor der mit Platzhalter — nachgestellt mit find-my-way, nicht aus dem Gedaechtnis behauptet. Geprueft: im Browser die Seite mit drei Beitraegen und die ohne (kein Thread, keine Begruendung, Hinweis statt Liste), der Weg von der Roadmap dorthin ohne Neuladen, Migration erneut durchgespielt. Co-Authored-By: Claude Opus 5 --- README.md | 4 +- frontend/src/App.jsx | 2 + frontend/src/locales/de.js | 9 ++ frontend/src/locales/en.js | 9 ++ frontend/src/pages/Roadmap.jsx | 5 +- frontend/src/pages/WunschDetail.jsx | 150 ++++++++++++++++++++++++++++ frontend/src/style.css | 44 ++++++++ src/bot/wishes.js | 46 ++++++++- src/db.js | 46 +++++++-- src/web/api.js | 22 ++++ 10 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/WunschDetail.jsx diff --git a/README.md b/README.md index 655caf3..6667305 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. 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 | +| 💡 **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. Ein Klick öffnet die **eigene Seite der Idee** mit der Unterhaltung aus dem Discord-Thread — gespiegelt, geschrieben wird weiter dort. 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 | @@ -144,6 +144,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | `/events` | öffentlich | Discord-Events (Playtests, Streams …) | | `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips | | `/feed.xml` | öffentlich | RSS-Feed der Devlogs | +| `/wunsch/:id` | öffentlich | Eine Idee mit Stand, Begründung und der Unterhaltung aus dem Thread | | `/` | öffentlich | Frei angelegte Seiten aus dem Seiten-Editor | | `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Angaben aus der Config (Owner-only) | | `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung | @@ -454,6 +455,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 /api/wishes/:id` · `/api/wishes/suche?q=` | — | Eine Idee mit Beiträgen aus dem Thread · Suche vor dem Einreichen | | `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) | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 43c9c96..b15dde3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import DevlogDetail from './pages/DevlogDetail.jsx'; import Impressum from './pages/Impressum.jsx'; import Datenschutz from './pages/Datenschutz.jsx'; import Seite from './pages/Seite.jsx'; +import WunschDetail from './pages/WunschDetail.jsx'; import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx'; import { useT, LangSwitch } from './i18n.jsx'; @@ -216,6 +217,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index afea091..44b63ba 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -127,6 +127,15 @@ export default { 'roadmap.status': 'Stand', 'roadmap.area': 'Bereich', 'roadmap.areaPick': '— Bereich —', + 'wunsch.tag': '// Idee', + 'wunsch.back': 'Zurück zur Roadmap', + 'wunsch.notFound': 'Diese Idee gibt es nicht (mehr).', + 'wunsch.by': 'von %s', + 'wunsch.teamSays': 'Vom Team', + 'wunsch.comments': '// %s Beiträge', + 'wunsch.oneComment': '// 1 Beitrag', + 'wunsch.noComments': 'Noch nichts dazu geschrieben — mach den Anfang im Thread.', + 'wunsch.joinThread': 'Im Discord mitreden', '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 5075949..2a632b5 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -126,6 +126,15 @@ export default { 'roadmap.status': 'Status', 'roadmap.area': 'Area', 'roadmap.areaPick': '— Area —', + 'wunsch.tag': '// Idea', + 'wunsch.back': 'Back to the roadmap', + 'wunsch.notFound': 'This idea does not exist (any more).', + 'wunsch.by': 'by %s', + 'wunsch.teamSays': 'From the team', + 'wunsch.comments': '// %s comments', + 'wunsch.oneComment': '// 1 comment', + 'wunsch.noComments': 'Nothing said about this yet — start it off in the thread.', + 'wunsch.joinThread': 'Join the thread on Discord', '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 69970d1..2c49c20 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; @@ -289,7 +290,7 @@ export default function Roadmap() { ) : ( {w.score} )} - + {status !== 'offen' && ( {t(WISH_STATUS[status])} @@ -304,7 +305,7 @@ export default function Roadmap() { {w.status_grund && ( {w.status_grund} )} - + {/* Geredet wird im Thread unter dem Post — der Zähler führt direkt dorthin */} {w.thread_url && ( diff --git a/frontend/src/pages/WunschDetail.jsx b/frontend/src/pages/WunschDetail.jsx new file mode 100644 index 0000000..c87dac0 --- /dev/null +++ b/frontend/src/pages/WunschDetail.jsx @@ -0,0 +1,150 @@ +// Ein Wunsch für sich: Idee, Stand, Begründung — und darunter, was im +// Discord-Thread dazu geschrieben wurde. +// +// Die Beiträge sind gespiegelt, nicht abgetippt: geschrieben wird weiter in +// Discord, hier steht nur mit, was dort steht. Deshalb führt jeder Weg zum +// Mitreden in den Thread und nicht in ein Formular. +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { apiGet, apiPost } from '../api.js'; +import { SkeletonCards } from '../components/Skeleton.jsx'; +import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx'; +import { useT, useFmt } from '../i18n.jsx'; + +const WISH_STATUS = { + offen: 'wish.offen', + geplant: 'wish.geplant', + in_arbeit: 'wish.inArbeit', + umgesetzt: 'wish.umgesetzt', + abgelehnt: 'wish.abgelehnt', +}; + +export default function WunschDetail() { + const { id } = useParams(); + const { t } = useT(); + const { datum } = useFmt(); + const [daten, setDaten] = useState(null); + const [fehler, setFehler] = useState(false); + + useEffect(() => { + setDaten(null); + setFehler(false); + apiGet(`/api/wishes/${id}`).then(setDaten).catch(() => setFehler(true)); + window.scrollTo(0, 0); + }, [id]); + + async function vote() { + try { + const res = await apiPost(`/api/wishes/${id}/vote`); + setDaten((d) => ({ + ...d, + wunsch: { + ...d.wunsch, + voted: res.voted, + score: d.wunsch.score + (res.voted ? 1 : -1), + }, + })); + } catch { /* nicht angemeldet oder kein Member — der Knopf bleibt einfach */ } + } + + const w = daten?.wunsch; + const status = w?.status ?? 'offen'; + + return ( + <> +
+
IDEE
+
+ +
+ + {t('wunsch.back')} + + + {fehler &&

{t('wunsch.notFound')}

} + {!fehler && !daten && } + + {w && ( + <> +
+
+ {daten.canVote ? ( + + ) : ( + {w.score} + )} +
+ {t(WISH_STATUS[status])} + {w.kategorie && ( + {w.kategorie_emoji} {w.kategorie} + )} +
+
+

{w.idea}

+
+ {t('wunsch.by', w.author ?? '—')} + {datum(w.created_at, { day: '2-digit', month: '2-digit', year: 'numeric' })} +
+ {w.status_grund && ( +
+ {t('wunsch.teamSays')} + {w.status_grund} +
+ )} +
+ +
+

+ {daten.kommentare.length === 1 + ? t('wunsch.oneComment') + : t('wunsch.comments', daten.kommentare.length)} +

+ + {daten.kommentare.length === 0 && ( +

{t('wunsch.noComments')}

+ )} + + {daten.kommentare.map((k) => ( +
+ {k.avatar + ? + : } +
+
+ {k.author} + + {datum(k.created_at, { + day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', + })} + +
+

{k.content}

+
+
+ ))} + + {/* Geschrieben wird in Discord — hier steht nur mit, was dort steht */} + {w.thread_url && ( + + {t('wunsch.joinThread')} + + )} +
+ + )} +
+ + ); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 4f334d8..f61d869 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2014,6 +2014,50 @@ label.toggle { .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; } +/* ── EINE IDEE FÜR SICH ────────────────────────────── */ +.wd-zurueck { margin-bottom: 1.2rem; display: inline-flex; align-items: center; gap: .4rem; } +.wd-kopf { padding: 1.3rem 1.5rem; margin-bottom: 2rem; } +.wd-zeile { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .9rem; } +.wd-schilder { display: flex; gap: .35rem; flex-wrap: wrap; } +.wd-idee { margin: 0 0 .8rem; font-size: 1.05rem; font-weight: 300; line-height: 1.6; } +.wd-meta { + display: flex; gap: 1rem; flex-wrap: wrap; + font-family: var(--mono); font-size: .64rem; color: var(--muted2); +} +.wd-antwort { + margin-top: 1.1rem; padding: .8rem 1rem; + background: color-mix(in srgb, var(--neon) 6%, transparent); + border-left: 2px solid var(--neon); + font-size: .92rem; color: var(--text); +} +.wd-antwort-kopf { + display: block; margin-bottom: .3rem; + font-family: var(--mono); font-size: .58rem; letter-spacing: .12em; + text-transform: uppercase; color: var(--neon); +} + +/* Beiträge aus dem Thread — gespiegelt, nicht geschrieben */ +.wd-komm { + display: flex; gap: .8rem; padding: .8rem 0; + border-bottom: 1px solid rgba(255, 255, 255, .05); +} +.wd-komm:last-of-type { border-bottom: none; } +.wd-avatar { width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto; } +.wd-avatar.leer { background: var(--bg3); } +.wd-komm-text { min-width: 0; flex: 1; } +.wd-komm-kopf { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; } +.wd-komm-name { font-weight: 600; font-size: .88rem; } +.wd-komm-zeit { font-family: var(--mono); font-size: .6rem; color: var(--muted2); } +.wd-komm-inhalt { + margin: .25rem 0 0; font-weight: 300; line-height: 1.55; + white-space: pre-wrap; overflow-wrap: anywhere; +} +.wd-mitreden { margin-top: 1.2rem; display: inline-flex; align-items: center; gap: .45rem; } + +/* Die Idee in der Liste führt jetzt zur eigenen Seite */ +a.wish-idea { color: inherit; text-decoration: none; } +a.wish-idea:hover { color: var(--neon); } + /* ── DIALOG ────────────────────────────────────────── */ .dlg-backdrop { position: fixed; inset: 0; z-index: 1200; diff --git a/src/bot/wishes.js b/src/bot/wishes.js index ae7703e..67e4b7c 100644 --- a/src/bot/wishes.js +++ b/src/bot/wishes.js @@ -14,7 +14,8 @@ import { ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js'; import { - saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories, + saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories, + saveWishComment, updateWishComment, deleteWishComment, } from '../db.js'; import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; @@ -223,14 +224,51 @@ export async function handleWunschModal(interaction) { } } -/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */ +/** + * Beiträge in Wunsch-Threads mitschreiben. + * + * Gespiegelt statt gezählt, damit die Detailseite die Unterhaltung zeigen + * kann. Bearbeiten und Löschen ziehen mit — sonst stünde auf der Webseite für + * immer, was in Discord längst zurückgenommen wurde. + */ export function registerWishThreads(client) { client.on(Events.MessageCreate, (message) => { try { if (message.author?.bot || !message.channel?.isThread?.()) return; - bumpWishKommentare(message.channelId); + const wunsch = wishByThread(message.channelId); + if (!wunsch) return; + const text = message.content?.trim(); + // Reine Bild-Posts haben keinen Text — die zeigt die Seite nicht + if (!text) return; + saveWishComment({ + message_id: message.id, + wish_id: wunsch.id, + author: message.member?.displayName ?? message.author.username, + author_id: message.author.id, + avatar: message.author.displayAvatarURL?.({ size: 64 }) ?? null, + content: text.slice(0, 2000), + created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '), + }); } catch (error) { - console.error('[voting] Kommentar-Zähler:', error); + console.error('[voting] Kommentar nicht gespeichert:', error); + } + }); + + client.on(Events.MessageUpdate, (alt, neu) => { + try { + if (!neu?.channel?.isThread?.()) return; + const text = neu.content?.trim(); + if (text) updateWishComment(neu.id, text.slice(0, 2000)); + } catch (error) { + console.error('[voting] Kommentar nicht geändert:', error); + } + }); + + client.on(Events.MessageDelete, (message) => { + try { + deleteWishComment(message.id); + } catch (error) { + console.error('[voting] Kommentar nicht entfernt:', error); } }); } diff --git a/src/db.js b/src/db.js index 23dfc38..2af9854 100644 --- a/src/db.js +++ b/src/db.js @@ -315,6 +315,37 @@ db.exec(` db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0'); } } +// Was im Thread unter einem Wunsch geschrieben wird. Gespiegelt, nicht +// gezaehlt: die Detailseite soll die Unterhaltung zeigen koennen, und 50 +// Threads je Seitenaufruf bei Discord abzufragen waere nicht zu bezahlen. +db.exec(` + CREATE TABLE IF NOT EXISTS wish_comments ( + message_id TEXT PRIMARY KEY, + wish_id INTEGER NOT NULL, + author TEXT, + author_id TEXT, + avatar TEXT, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at); +`); +const insertCommentStmt = db.prepare(` + INSERT OR REPLACE INTO wish_comments + (message_id, wish_id, author, author_id, avatar, content, created_at) + VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @created_at) +`); +const updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?'); +const deleteCommentStmt = db.prepare('DELETE FROM wish_comments WHERE message_id = ?'); +const listCommentsStmt = db.prepare( + 'SELECT * FROM wish_comments WHERE wish_id = ? ORDER BY created_at LIMIT ?' +); +const dropCommentsStmt = db.prepare('DELETE FROM wish_comments WHERE wish_id = ?'); +export const saveWishComment = (c) => insertCommentStmt.run(c); +export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0; +export const deleteWishComment = (messageId) => deleteCommentStmt.run(messageId).changes > 0; +export const wishComments = (wishId, limit = 200) => listCommentsStmt.all(wishId, limit); + 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( @@ -338,7 +369,8 @@ export const deleteWishCategory = db.transaction((id) => { /** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */ const WUNSCH_FELDER = ` 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, + w.status_grund, w.status_at, w.created_at, w.category_id, + (SELECT COUNT(*) FROM wish_comments c WHERE c.wish_id = w.id) AS kommentare, (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, @@ -351,9 +383,7 @@ const insertWish = db.prepare( '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 wishByThreadStmt = db.prepare('SELECT * FROM wishes 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 = ?'); @@ -387,18 +417,18 @@ export const saveWish = (messageId, author, idea, authorId = null, categoryId = 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 wishByThread = (threadId) => wishByThreadStmt.get(threadId) ?? null; 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, sortierung = 'top') => (topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit); export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit); -export const deleteWish = (id) => { +export const deleteWish = db.transaction((id) => { dropVotesStmt.run(id); + dropCommentsStmt.run(id); return deleteWishStmt.run(id).changes > 0; -}; +}); export const setWishStatus = (id, status, grund) => setWishStatusStmt.run(status, grund || null, id).changes > 0; diff --git a/src/web/api.js b/src/web/api.js index 4a0dc54..87ddcab 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -16,6 +16,7 @@ import { saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory, + wishComments, listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory, logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays, heartbeatBuckets, heartbeatFirst, @@ -1984,6 +1985,27 @@ ${rssItems} return { voted: toggleWishVote(id, user.id) }; }); + // Ein einzelner Wunsch mit allem, was im Thread darunter steht. + // Muss vor /api/wishes/:id/... stehen? Nein — andere Tiefe, kein Konflikt. + app.get('/api/wishes/:id', async (request, reply) => { + const id = Number(request.params.id); + const wunsch = getWish(id); + if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' }); + const user = getSessionUser(request); + const gid = discordGuildId(); + return { + wunsch: { + ...wunsch, + voted: user ? myWishVotes(user.id).includes(id) : false, + thread_url: gid && wunsch.thread_id + ? `https://discord.com/channels/${gid}/${wunsch.thread_id}` + : null, + }, + kommentare: wishComments(id), + canVote: Boolean(user), + }; + }); + // Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen app.get('/api/wishes/suche', async (request) => { const q = String(request.query?.q ?? '').trim();