diff --git a/README.md b/README.md index 6667305..13297ef 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. 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 | +| 💡 **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 samt Screenshots, geschrieben wird weiter dort. Fehlende Forum-Tags legt das Panel auf Knopfdruck an. 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 | @@ -450,7 +450,7 @@ urllib.request.urlopen(req) | `GET /api/servers` · `/api/status` | — | Game-Server-Status · öffentlicher Betriebsstatus (Namen, nie Adressen) | | `GET /api/pages` · `/api/features` | — | Veröffentlichte Seiten · Funktionsliste für die Produktseite | | `GET /feed.xml` | — | RSS | -| `GET /devlog-assets/*` · `/gallery-assets/*` | — | Lokal gespeicherte Bilder | +| `GET /devlog-assets/*` · `/gallery-assets/*` · `/wish-assets/*` | — | Lokal gespeicherte Bilder (Discord-Links laufen ab) | | `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) | | `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 | @@ -511,6 +511,8 @@ d4rkbot/ │ │ ├── server-monitor.js # Game-Server (gamedig, 300+ Spiele) │ │ ├── watchdog.js # URL-Checks, Störungen, DM-Alarm │ │ ├── heartbeat.js # Eigene Erreichbarkeit aufzeichnen +│ │ ├── wishes.js # Ideen: einreichen, Thread, Forum-Tags +│ │ ├── bilder.js # Bilder aus Discord lokal sichern │ │ ├── server-setup.js # Kanäle und Rollen anlegen │ │ ├── welcome-card.js # Willkommens-Bild rendern │ │ ├── social-notify.js # Twitch + YouTube diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 23494a3..6dcf9f9 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1887,12 +1887,14 @@ export default function Settings({ me }) {

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. + Forums-Beitrag und bekommt Tags — dann kann man auch in Discord danach + filtern, und der Stand zieht beim Ändern automatisch mit. Der Knopf legt die + fehlenden Tags an: die Bereiche von hier plus die fünf Stände. Vorhandene + bleiben, wie sie sind.

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

Noch keine Bereiche.

} @@ -2174,6 +2176,20 @@ export default function Settings({ me }) { } } + /** Fehlende Tags im Forum anlegen — Bereiche plus die fuenf Staende */ + async function forumTags() { + try { + const res = await apiPost('/api/wish-categories/tags'); + const teile = []; + if (res.angelegt.length) teile.push(`${res.angelegt.length} angelegt`); + if (res.schonDa.length) teile.push(`${res.schonDa.length} gab es schon`); + if (res.keinPlatz.length) teile.push(`${res.keinPlatz.length} passten nicht mehr rein`); + flash(`✓ ${teile.join(', ') || 'nichts zu tun'}`); + } 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 { diff --git a/frontend/src/pages/WunschDetail.jsx b/frontend/src/pages/WunschDetail.jsx index c87dac0..6ed04bb 100644 --- a/frontend/src/pages/WunschDetail.jsx +++ b/frontend/src/pages/WunschDetail.jsx @@ -8,6 +8,7 @@ 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 Lightbox from '../components/Lightbox.jsx'; import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; @@ -25,6 +26,7 @@ export default function WunschDetail() { const { datum } = useFmt(); const [daten, setDaten] = useState(null); const [fehler, setFehler] = useState(false); + const [lightbox, setLightbox] = useState(null); useEffect(() => { setDaten(null); @@ -130,7 +132,26 @@ export default function WunschDetail() { })} -

{k.content}

+ {k.content &&

{k.content}

} + {/* Bilder liegen lokal — Discord-Links + laufen ab und waeren morgen kaputt */} + {k.images?.length > 0 && ( +
+ {k.images.map((datei, i) => ( + setLightbox({ + images: k.images.map((f) => `/wish-assets/${f}`), + start: i, + })} + /> + ))} +
+ )} ))} @@ -145,6 +166,14 @@ export default function WunschDetail() { )} + + {lightbox && ( + setLightbox(null)} + /> + )} ); } diff --git a/frontend/src/style.css b/frontend/src/style.css index f61d869..5be7b73 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2052,6 +2052,12 @@ label.toggle { margin: .25rem 0 0; font-weight: 300; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } +.wd-bilder { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; } +.wd-bild { + max-height: 180px; max-width: 100%; border-radius: 6px; cursor: zoom-in; + border: 1px solid var(--border); object-fit: cover; +} +.wd-bild:hover { border-color: var(--neon); } .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 */ diff --git a/src/bot/bilder.js b/src/bot/bilder.js new file mode 100644 index 0000000..9339783 --- /dev/null +++ b/src/bot/bilder.js @@ -0,0 +1,54 @@ +// Bilder aus Discord-Nachrichten lokal sichern. +// +// Discord-CDN-Links laufen ab — wer sie nur speichert, hat nach ein paar Wochen +// eine Seite voller kaputter Vorschaubilder. Deshalb wird heruntergeladen und +// vom Webserver selbst ausgeliefert. +// +// Stand vorher dreimal fast gleich im Code (Devlogs, Galerie, jetzt Wünsche); +// hier einmal, damit ein Fehler auch nur einmal drinsteckt. +import { mkdirSync } from 'node:fs'; +import { writeFile, rm } from 'node:fs/promises'; +import { join, dirname, resolve } from 'node:path'; +import { config } from '../config.js'; + +/** Ordner neben der SQLite anlegen und zurückgeben */ +export function bildOrdner(name) { + const dir = join(dirname(resolve(config.dbPath)), name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Bild-Anhänge einer Nachricht herunterladen. + * + * @param {import('discord.js').Message} message + * @param {{ dir: string, max?: number, tag?: string }} opts + * @returns {Promise} gespeicherte Dateinamen + */ +export async function bilderSichern(message, { dir, max = 4, tag = 'bilder' }) { + const files = []; + for (const att of [...(message.attachments?.values?.() ?? [])]) { + if (files.length >= max) break; + if (!att.contentType?.startsWith('image/')) continue; + + // Endung säubern: der Name kommt vom Hochladenden und landet im Dateisystem + const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'; + const file = `${message.id}_${files.length}.${ext}`; + try { + const res = await fetch(att.url); + if (!res.ok) continue; + await writeFile(join(dir, file), Buffer.from(await res.arrayBuffer())); + files.push(file); + } catch (error) { + console.error(`[${tag}] Bild-Download fehlgeschlagen (${att.url}):`, error.message); + } + } + return files; +} + +/** Gespeicherte Bilder wieder wegräumen */ +export async function bilderLoeschen(dir, files) { + for (const file of files ?? []) { + await rm(join(dir, file), { force: true }); + } +} diff --git a/src/bot/community.js b/src/bot/community.js index edfaa88..e8c636c 100644 --- a/src/bot/community.js +++ b/src/bot/community.js @@ -1,7 +1,4 @@ // Community-Features: Starboard (⭐ → Best-of-Kanal) + Screenshot-Galerie -import { mkdirSync } from 'node:fs'; -import { writeFile, rm } from 'node:fs/promises'; -import { join, dirname, resolve } from 'node:path'; import { EmbedBuilder, Events } from 'discord.js'; import { hasStarboard, saveStarboard, @@ -10,12 +7,11 @@ import { import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { tuning } from '../tuning.js'; -import { config } from '../config.js'; +import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js'; // Galerie-Bilder liegen neben der SQLite: data/gallery/ -export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery'); -mkdirSync(galleryDir, { recursive: true }); +export const galleryDir = bildOrdner('gallery'); /* ── Screenshot-Galerie ────────────────────────────── */ @@ -27,21 +23,9 @@ export async function archiveGalleryMessage(message) { if (!moduleEnabled('gallery')) return; if (hasGalleryItem(message.id)) return false; - const files = []; - for (const att of message.attachments?.values?.() ?? []) { - if (files.length >= tuning('gallery_max_images')) break; - if (!att.contentType?.startsWith('image/')) continue; - const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'; - const file = `${message.id}_${files.length}.${ext}`; - try { - const res = await fetch(att.url); - if (!res.ok) continue; - await writeFile(join(galleryDir, file), Buffer.from(await res.arrayBuffer())); - files.push(file); - } catch (error) { - console.error(`[galerie] Bild-Download fehlgeschlagen: ${error.message}`); - } - } + const files = await bilderSichern(message, { + dir: galleryDir, max: tuning('gallery_max_images'), tag: 'galerie', + }); if (files.length === 0) return false; return saveGalleryItem({ @@ -55,9 +39,7 @@ export async function archiveGalleryMessage(message) { /** Galerie-Eintrag inkl. Bild-Dateien entfernen */ export async function removeGalleryMessage(messageId) { const { deleted, images } = deleteGalleryItem(messageId); - for (const file of images) { - await rm(join(galleryDir, file), { force: true }); - } + await bilderLoeschen(galleryDir, images); return deleted; } diff --git a/src/bot/devlog-archive.js b/src/bot/devlog-archive.js index 89a925e..fb2c7f1 100644 --- a/src/bot/devlog-archive.js +++ b/src/bot/devlog-archive.js @@ -2,17 +2,13 @@ // Bilder werden lokal gespeichert (Discord-CDN-Links laufen ab!) und vom // Webserver unter /devlog-assets/ ausgeliefert. // Wird vom Live-Listener (MessageCreate) und vom /devlog-backfill-Command genutzt. -import { mkdirSync } from 'node:fs'; -import { writeFile, rm } from 'node:fs/promises'; -import { join, dirname, resolve } from 'node:path'; import { saveDevlog, hasDevlog, deleteDevlog } from '../db.js'; -import { config } from '../config.js'; +import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js'; const MAX_IMAGES = 4; // Bilder liegen neben der SQLite: data/devlog_images/ -export const imagesDir = join(dirname(resolve(config.dbPath)), 'devlog_images'); -mkdirSync(imagesDir, { recursive: true }); +export const imagesDir = bildOrdner('devlog_images'); /** Text aus einer Nachricht ziehen — Plain-Content plus Embed-Titel/-Beschreibungen */ function extractContent(message) { @@ -28,26 +24,8 @@ function extractContent(message) { } /** Bild-Attachments herunterladen, gespeicherte Dateinamen zurückgeben */ -async function downloadImages(message) { - const files = []; - const attachments = [...(message.attachments?.values?.() ?? [])]; - for (const att of attachments) { - if (files.length >= MAX_IMAGES) break; - if (!att.contentType?.startsWith('image/')) continue; - - const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'; - const file = `${message.id}_${files.length}.${ext}`; - try { - const res = await fetch(att.url); - if (!res.ok) continue; - await writeFile(join(imagesDir, file), Buffer.from(await res.arrayBuffer())); - files.push(file); - } catch (error) { - console.error(`[devlog] Bild-Download fehlgeschlagen (${att.url}):`, error.message); - } - } - return files; -} +const downloadImages = (message) => + bilderSichern(message, { dir: imagesDir, max: MAX_IMAGES, tag: 'devlog' }); /** * Devlog aus dem Archiv entfernen (inkl. lokal gespeicherter Bilder). @@ -55,9 +33,7 @@ async function downloadImages(message) { */ export async function removeDevlog(messageId) { const { deleted, images } = deleteDevlog(messageId); - for (const file of images) { - await rm(join(imagesDir, file), { force: true }); - } + await bilderLoeschen(imagesDir, images); return deleted; } diff --git a/src/bot/wishes.js b/src/bot/wishes.js index 67e4b7c..2641413 100644 --- a/src/bot/wishes.js +++ b/src/bot/wishes.js @@ -15,13 +15,17 @@ import { } from 'discord.js'; import { saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories, - saveWishComment, updateWishComment, deleteWishComment, + saveWishComment, updateWishComment, deleteWishComment, wishCommentImagesOf, } from '../db.js'; +import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.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'; +// Bilder aus den Threads liegen neben der SQLite, wie bei Devlogs und Galerie +export const wunschBilderDir = bildOrdner('wish_images'); + /** * 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 @@ -164,6 +168,48 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata return { id, url: message.url }; } +/** + * Fehlende Tags im Forum anlegen — die Bereiche und die fünf Stände. + * + * Discord erlaubt 20 Tags je Forum. Vorhandene bleiben unangetastet, auch + * fremde: die Liste wird ergänzt, nicht ersetzt. + * + * @returns {Promise<{angelegt: string[], schonDa: string[], keinPlatz: string[]}>} + */ +export async function forumTagsAnlegen(client) { + const channelId = votingChannelId(); + if (!channelId) throw new Error('Kein Voting-Kanal eingestellt.'); + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!istForum(channel)) throw new Error('Der Voting-Kanal ist kein Forum.'); + + const vorhanden = channel.availableTags ?? []; + const kennt = new Set(vorhanden.map((t) => t.name.toLowerCase())); + + // Bereiche zuerst — die Stände sind ohne sie immer noch nützlich, umgekehrt + // sortiert sich ohne Bereiche nichts + const gewuenscht = [ + ...listWishCategories().map((b) => ({ name: b.name, emoji: alsEmoji(b.emoji) })), + ...Object.entries(STATUS_LABEL).map(([id, name]) => ({ + name, emoji: alsEmoji(STATUS_EMOJI[id]), + })), + ]; + + const angelegt = []; + const schonDa = []; + const keinPlatz = []; + const neu = [...vorhanden]; + for (const t of gewuenscht) { + if (kennt.has(t.name.toLowerCase())) { schonDa.push(t.name); continue; } + if (neu.length >= 20) { keinPlatz.push(t.name); continue; } + neu.push({ name: t.name.slice(0, 20), emoji: t.emoji, moderated: false }); + kennt.add(t.name.toLowerCase()); + angelegt.push(t.name); + } + + if (angelegt.length > 0) await channel.setAvailableTags(neu); + return { angelegt, schonDa, keinPlatz }; +} + /** * Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte * Stand fliegt raus, der neue kommt rein, der Bereich bleibt. @@ -232,21 +278,28 @@ export async function handleWunschModal(interaction) { * immer, was in Discord längst zurückgenommen wurde. */ export function registerWishThreads(client) { - client.on(Events.MessageCreate, (message) => { + client.on(Events.MessageCreate, async (message) => { try { if (message.author?.bot || !message.channel?.isThread?.()) return; 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; + const hatBilder = [...(message.attachments?.values?.() ?? [])] + .some((a) => a.contentType?.startsWith('image/')); + // Ohne Text und ohne Bild gibt es nichts zu zeigen + if (!text && !hatBilder) return; + // Discord-Links laufen ab — deshalb herunterladen statt verweisen + const bilder = hatBilder + ? await bilderSichern(message, { dir: wunschBilderDir, max: 4, tag: 'voting' }) + : []; 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), + content: (text ?? '').slice(0, 2000), + images: JSON.stringify(bilder), created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '), }); } catch (error) { @@ -264,9 +317,10 @@ export function registerWishThreads(client) { } }); - client.on(Events.MessageDelete, (message) => { + client.on(Events.MessageDelete, async (message) => { try { - deleteWishComment(message.id); + const bilder = wishCommentImagesOf(message.id); + if (deleteWishComment(message.id)) await bilderLoeschen(wunschBilderDir, bilder); } catch (error) { console.error('[voting] Kommentar nicht entfernt:', error); } diff --git a/src/db.js b/src/db.js index 2af9854..32733b7 100644 --- a/src/db.js +++ b/src/db.js @@ -326,14 +326,21 @@ db.exec(` author_id TEXT, avatar TEXT, content TEXT NOT NULL, + images TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at); `); +{ + const spalten = db.prepare('PRAGMA table_info(wish_comments)').all().map((c) => c.name); + if (!spalten.includes('images')) { + db.exec(`ALTER TABLE wish_comments ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); + } +} 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) + (message_id, wish_id, author, author_id, avatar, content, images, created_at) + VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @images, @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 = ?'); @@ -344,7 +351,15 @@ 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); +export const wishComments = (wishId, limit = 200) => + listCommentsStmt.all(wishId, limit).map((c) => ({ ...c, images: JSON.parse(c.images || '[]') })); +/** Alle Bilddateien eines Wunsches — zum Wegräumen beim Löschen */ +const wishImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE wish_id = ?'); +export const wishCommentImages = (wishId) => + wishImagesStmt.all(wishId).flatMap((r) => JSON.parse(r.images || '[]')); +const commentImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE message_id = ?'); +export const wishCommentImagesOf = (messageId) => + JSON.parse(commentImagesStmt.get(messageId)?.images || '[]'); const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id'); const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?'); diff --git a/src/templates.js b/src/templates.js index dd734d5..e40ef61 100644 --- a/src/templates.js +++ b/src/templates.js @@ -188,7 +188,8 @@ export const TEMPLATES = [ id: 'wish.submitted', group: 'sonstiges', module: 'voting', name: 'Feature-Wunsch — Bestätigung', long: true, vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }], - default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!', + default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!\n' + + 'Screenshot dazu? Häng ihn einfach in den Thread — er landet mit auf der Ideen-Seite.', }, { id: 'wish.panel_title', group: 'sonstiges', module: 'voting', diff --git a/src/web/api.js b/src/web/api.js index 87ddcab..e53b90b 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -16,7 +16,7 @@ import { saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory, - wishComments, + wishComments, wishCommentImages, listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory, logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays, heartbeatBuckets, heartbeatFirst, @@ -36,8 +36,10 @@ 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, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI, + wunschAnlegen, wunschTagsAktualisieren, forumTagsAnlegen, wunschBilderDir, + STATUS_LABEL, STATUS_EMOJI, } from '../bot/wishes.js'; +import { bilderLoeschen } from '../bot/bilder.js'; import { heartbeatSerie } from '../bot/heartbeat.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; @@ -2058,6 +2060,19 @@ ${rssItems} return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) }; }); + // Fehlende Tags im Forum anlegen — spart das Abtippen der Bereichsnamen + // und der fuenf Staende, und Tippfehler waeren hier stumme Fehlschlaege + app.post('/api/wish-categories/tags', async (request, reply) => { + if (requireScope(request, reply, 'community')) return; + try { + const res = await forumTagsAnlegen(client); + logAudit(getSessionUser(request), 'forum-tags angelegt', res.angelegt.join(', ') || 'keine'); + return { ok: true, ...res }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + // --- Wünsche verwalten (Team) --- const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [ @@ -2162,7 +2177,10 @@ ${rssItems} if (requireScope(request, reply, 'community')) return; const wunsch = getWish(Number(request.params.id)); if (!wunsch) return { deleted: false, wishes: topWishes(50) }; + // Erst die Dateinamen holen, dann loeschen — danach sind sie weg + const bilder = wishCommentImages(wunsch.id); deleteWish(wunsch.id); + await bilderLoeschen(wunschBilderDir, bilder); logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60)); return { deleted: true, wishes: topWishes(50) }; }); diff --git a/src/web/server.js b/src/web/server.js index 0b63566..778c4c6 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -95,6 +95,16 @@ export async function startWebServer(client) { immutable: true, }); + // Bilder aus Wunsch-Threads + const { wunschBilderDir } = await import('../bot/wishes.js'); + await app.register(fastifyStatic, { + root: wunschBilderDir, + prefix: '/wish-assets/', + decorateReply: false, + maxAge: '30d', + immutable: true, + }); + if (existsSync(frontendDist)) { // React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing). // Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,