Screenshots zu Ideen, und der Bot legt die Forum-Tags selbst an
Zwei Fragen, zwei Antworten — eine davon ein Nein mit Umweg. ## Bilder im Eingabefenster: geht nicht Discord-Modals koennen nur Textfelder. Kein Datei-Anhang, keine Umgehung — das ist eine Grenze der Plattform, nicht unseres Codes. Der Umweg ist besser als er klingt: Wer nach dem Einreichen einen Screenshot in den Thread haengt, sieht ihn auf der Ideen-Seite. Die Bestaetigung nach dem Absenden sagt das jetzt auch. Damit kann man auch nachtraeglich noch Bilder dazulegen, was im Eingabefenster gar nicht ginge. ## Warum nicht einfach der Discord-Link Discord haengt seit einiger Zeit eine Signatur an die Anhang-Links (?ex=&is=&hm=), die nach etwa einem Tag ablaeuft. Ein gespeicherter Link zeigt morgen ein kaputtes Bild. Deshalb laden Devlogs und Galerie seit dem ersten Tag herunter — im Kopf von devlog-archive.js steht der Grund. Wuensche machen es jetzt genauso, ausgeliefert unter /wish-assets/. Beim Loeschen eines Beitrags oder eines ganzen Wunsches gehen die Dateien mit; sonst waechst der Ordner mit Bildern, auf die nichts mehr zeigt. Das Herunterladen stand vorher zweimal fast wortgleich im Code. Mit den Wuenschen waeren es drei Kopien geworden — also einmal nach bot/bilder.js, und Devlogs und Galerie ziehen mit um. Ein Fehler steckt jetzt an einer Stelle statt an dreien. ## Forum-Tags per Knopf Bisher musstest du sie von Hand anlegen. Jetzt macht es das Panel: die Bereiche von dort plus die fuenf Staende, mit ihren Emojis. Vorhandene bleiben, auch fremde — die Liste wird ergaenzt und nicht ersetzt. Discords Grenze von 20 Tags wird eingehalten, was nicht mehr reinpasst, wird gemeldet statt still verschluckt. Geprueft: forumTagsAnlegen gegen eine Kanal-Attrappe — leeres Forum, teilweise vorhandene, Gross/Kleinschreibung, fremde Tags, nichts zu tun (dann wird auch nicht geschrieben), volles Forum, kein Forum, kein Kanal. Im Browser die Ideen-Seite mit Bildern in einem Beitrag, einem Beitrag nur aus Bild ohne Text, und der Lightbox darueber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 📈 **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 |
|
| ⭐ **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) |
|
| 📸 **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 |
|
| 🗳️ **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 |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
||||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
| 🎂 **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/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 /api/pages` · `/api/features` | — | Veröffentlichte Seiten · Funktionsliste für die Produktseite |
|
||||||
| `GET /feed.xml` | — | RSS |
|
| `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 /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/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 |
|
| `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)
|
│ │ ├── server-monitor.js # Game-Server (gamedig, 300+ Spiele)
|
||||||
│ │ ├── watchdog.js # URL-Checks, Störungen, DM-Alarm
|
│ │ ├── watchdog.js # URL-Checks, Störungen, DM-Alarm
|
||||||
│ │ ├── heartbeat.js # Eigene Erreichbarkeit aufzeichnen
|
│ │ ├── 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
|
│ │ ├── server-setup.js # Kanäle und Rollen anlegen
|
||||||
│ │ ├── welcome-card.js # Willkommens-Bild rendern
|
│ │ ├── welcome-card.js # Willkommens-Bild rendern
|
||||||
│ │ ├── social-notify.js # Twitch + YouTube
|
│ │ ├── social-notify.js # Twitch + YouTube
|
||||||
|
|||||||
@@ -1887,12 +1887,14 @@ export default function Settings({ me }) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="section-intro">
|
<p className="section-intro">
|
||||||
Ist der Voting-Kanal ein <strong>Forum</strong>, wird jeder Wunsch ein
|
Ist der Voting-Kanal ein <strong>Forum</strong>, wird jeder Wunsch ein
|
||||||
Forums-Beitrag und bekommt Tags. Leg dort Tags mit denselben Namen an wie hier
|
Forums-Beitrag und bekommt Tags — dann kann man auch in Discord danach
|
||||||
die Bereiche, dazu <em>Wird geprüft</em>, <em>Geplant</em>, <em>In Arbeit</em>,
|
filtern, und der Stand zieht beim Ändern automatisch mit. Der Knopf legt die
|
||||||
<em>Umgesetzt</em> und <em>Nicht geplant</em> — dann kann man auch in Discord
|
fehlenden Tags an: die Bereiche von hier plus die fünf Stände. Vorhandene
|
||||||
danach filtern, und der Stand zieht automatisch mit. Fehlt ein Tag, bleibt es
|
bleiben, wie sie sind.
|
||||||
einfach ohne.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="field-row" style={{ marginBottom: '1rem' }}>
|
||||||
|
<button className="btn" onClick={forumTags}>Fehlende Tags im Forum anlegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{bereiche.length === 0 && <p className="notice">Noch keine Bereiche.</p>}
|
{bereiche.length === 0 && <p className="notice">Noch keine Bereiche.</p>}
|
||||||
|
|
||||||
@@ -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) {
|
async function bereichLoeschen(b) {
|
||||||
if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return;
|
if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { apiGet, apiPost } from '../api.js';
|
import { apiGet, apiPost } from '../api.js';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx';
|
import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx';
|
||||||
import { useT, useFmt } from '../i18n.jsx';
|
import { useT, useFmt } from '../i18n.jsx';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export default function WunschDetail() {
|
|||||||
const { datum } = useFmt();
|
const { datum } = useFmt();
|
||||||
const [daten, setDaten] = useState(null);
|
const [daten, setDaten] = useState(null);
|
||||||
const [fehler, setFehler] = useState(false);
|
const [fehler, setFehler] = useState(false);
|
||||||
|
const [lightbox, setLightbox] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDaten(null);
|
setDaten(null);
|
||||||
@@ -130,7 +132,26 @@ export default function WunschDetail() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="wd-komm-inhalt">{k.content}</p>
|
{k.content && <p className="wd-komm-inhalt">{k.content}</p>}
|
||||||
|
{/* Bilder liegen lokal — Discord-Links
|
||||||
|
laufen ab und waeren morgen kaputt */}
|
||||||
|
{k.images?.length > 0 && (
|
||||||
|
<div className="wd-bilder">
|
||||||
|
{k.images.map((datei, i) => (
|
||||||
|
<img
|
||||||
|
key={datei}
|
||||||
|
className="wd-bild"
|
||||||
|
src={`/wish-assets/${datei}`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => setLightbox({
|
||||||
|
images: k.images.map((f) => `/wish-assets/${f}`),
|
||||||
|
start: i,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -145,6 +166,14 @@ export default function WunschDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<Lightbox
|
||||||
|
images={lightbox.images}
|
||||||
|
start={lightbox.start}
|
||||||
|
onClose={() => setLightbox(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2052,6 +2052,12 @@ label.toggle {
|
|||||||
margin: .25rem 0 0; font-weight: 300; line-height: 1.55;
|
margin: .25rem 0 0; font-weight: 300; line-height: 1.55;
|
||||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
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; }
|
.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 */
|
/* Die Idee in der Liste führt jetzt zur eigenen Seite */
|
||||||
|
|||||||
@@ -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<string[]>} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-24
@@ -1,7 +1,4 @@
|
|||||||
// Community-Features: Starboard (⭐ → Best-of-Kanal) + Screenshot-Galerie
|
// 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 { EmbedBuilder, Events } from 'discord.js';
|
||||||
import {
|
import {
|
||||||
hasStarboard, saveStarboard,
|
hasStarboard, saveStarboard,
|
||||||
@@ -10,12 +7,11 @@ import {
|
|||||||
import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js';
|
import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js';
|
||||||
import { moduleEnabled } from '../modules.js';
|
import { moduleEnabled } from '../modules.js';
|
||||||
import { tuning } from '../tuning.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/
|
// Galerie-Bilder liegen neben der SQLite: data/gallery/
|
||||||
export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery');
|
export const galleryDir = bildOrdner('gallery');
|
||||||
mkdirSync(galleryDir, { recursive: true });
|
|
||||||
|
|
||||||
/* ── Screenshot-Galerie ────────────────────────────── */
|
/* ── Screenshot-Galerie ────────────────────────────── */
|
||||||
|
|
||||||
@@ -27,21 +23,9 @@ export async function archiveGalleryMessage(message) {
|
|||||||
if (!moduleEnabled('gallery')) return;
|
if (!moduleEnabled('gallery')) return;
|
||||||
if (hasGalleryItem(message.id)) return false;
|
if (hasGalleryItem(message.id)) return false;
|
||||||
|
|
||||||
const files = [];
|
const files = await bilderSichern(message, {
|
||||||
for (const att of message.attachments?.values?.() ?? []) {
|
dir: galleryDir, max: tuning('gallery_max_images'), tag: 'galerie',
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
|
|
||||||
return saveGalleryItem({
|
return saveGalleryItem({
|
||||||
@@ -55,9 +39,7 @@ export async function archiveGalleryMessage(message) {
|
|||||||
/** Galerie-Eintrag inkl. Bild-Dateien entfernen */
|
/** Galerie-Eintrag inkl. Bild-Dateien entfernen */
|
||||||
export async function removeGalleryMessage(messageId) {
|
export async function removeGalleryMessage(messageId) {
|
||||||
const { deleted, images } = deleteGalleryItem(messageId);
|
const { deleted, images } = deleteGalleryItem(messageId);
|
||||||
for (const file of images) {
|
await bilderLoeschen(galleryDir, images);
|
||||||
await rm(join(galleryDir, file), { force: true });
|
|
||||||
}
|
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
// Bilder werden lokal gespeichert (Discord-CDN-Links laufen ab!) und vom
|
// Bilder werden lokal gespeichert (Discord-CDN-Links laufen ab!) und vom
|
||||||
// Webserver unter /devlog-assets/ ausgeliefert.
|
// Webserver unter /devlog-assets/ ausgeliefert.
|
||||||
// Wird vom Live-Listener (MessageCreate) und vom /devlog-backfill-Command genutzt.
|
// 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 { saveDevlog, hasDevlog, deleteDevlog } from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js';
|
||||||
|
|
||||||
const MAX_IMAGES = 4;
|
const MAX_IMAGES = 4;
|
||||||
|
|
||||||
// Bilder liegen neben der SQLite: data/devlog_images/
|
// Bilder liegen neben der SQLite: data/devlog_images/
|
||||||
export const imagesDir = join(dirname(resolve(config.dbPath)), 'devlog_images');
|
export const imagesDir = bildOrdner('devlog_images');
|
||||||
mkdirSync(imagesDir, { recursive: true });
|
|
||||||
|
|
||||||
/** Text aus einer Nachricht ziehen — Plain-Content plus Embed-Titel/-Beschreibungen */
|
/** Text aus einer Nachricht ziehen — Plain-Content plus Embed-Titel/-Beschreibungen */
|
||||||
function extractContent(message) {
|
function extractContent(message) {
|
||||||
@@ -28,26 +24,8 @@ function extractContent(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Bild-Attachments herunterladen, gespeicherte Dateinamen zurückgeben */
|
/** Bild-Attachments herunterladen, gespeicherte Dateinamen zurückgeben */
|
||||||
async function downloadImages(message) {
|
const downloadImages = (message) =>
|
||||||
const files = [];
|
bilderSichern(message, { dir: imagesDir, max: MAX_IMAGES, tag: 'devlog' });
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Devlog aus dem Archiv entfernen (inkl. lokal gespeicherter Bilder).
|
* Devlog aus dem Archiv entfernen (inkl. lokal gespeicherter Bilder).
|
||||||
@@ -55,9 +33,7 @@ async function downloadImages(message) {
|
|||||||
*/
|
*/
|
||||||
export async function removeDevlog(messageId) {
|
export async function removeDevlog(messageId) {
|
||||||
const { deleted, images } = deleteDevlog(messageId);
|
const { deleted, images } = deleteDevlog(messageId);
|
||||||
for (const file of images) {
|
await bilderLoeschen(imagesDir, images);
|
||||||
await rm(join(imagesDir, file), { force: true });
|
|
||||||
}
|
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-7
@@ -15,13 +15,17 @@ import {
|
|||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import {
|
import {
|
||||||
saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories,
|
saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories,
|
||||||
saveWishComment, updateWishComment, deleteWishComment,
|
saveWishComment, updateWishComment, deleteWishComment, wishCommentImagesOf,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
|
import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js';
|
||||||
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
||||||
import { renderTemplate } from '../templates.js';
|
import { renderTemplate } from '../templates.js';
|
||||||
import { tuning } from '../tuning.js';
|
import { tuning } from '../tuning.js';
|
||||||
import { alsEmoji } from './tickets.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 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
|
* 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 };
|
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
|
* Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte
|
||||||
* Stand fliegt raus, der neue kommt rein, der Bereich bleibt.
|
* 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.
|
* immer, was in Discord längst zurückgenommen wurde.
|
||||||
*/
|
*/
|
||||||
export function registerWishThreads(client) {
|
export function registerWishThreads(client) {
|
||||||
client.on(Events.MessageCreate, (message) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
try {
|
try {
|
||||||
if (message.author?.bot || !message.channel?.isThread?.()) return;
|
if (message.author?.bot || !message.channel?.isThread?.()) return;
|
||||||
const wunsch = wishByThread(message.channelId);
|
const wunsch = wishByThread(message.channelId);
|
||||||
if (!wunsch) return;
|
if (!wunsch) return;
|
||||||
const text = message.content?.trim();
|
const text = message.content?.trim();
|
||||||
// Reine Bild-Posts haben keinen Text — die zeigt die Seite nicht
|
const hatBilder = [...(message.attachments?.values?.() ?? [])]
|
||||||
if (!text) return;
|
.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({
|
saveWishComment({
|
||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
wish_id: wunsch.id,
|
wish_id: wunsch.id,
|
||||||
author: message.member?.displayName ?? message.author.username,
|
author: message.member?.displayName ?? message.author.username,
|
||||||
author_id: message.author.id,
|
author_id: message.author.id,
|
||||||
avatar: message.author.displayAvatarURL?.({ size: 64 }) ?? null,
|
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', ' '),
|
created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -264,9 +317,10 @@ export function registerWishThreads(client) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.MessageDelete, (message) => {
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
try {
|
try {
|
||||||
deleteWishComment(message.id);
|
const bilder = wishCommentImagesOf(message.id);
|
||||||
|
if (deleteWishComment(message.id)) await bilderLoeschen(wunschBilderDir, bilder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[voting] Kommentar nicht entfernt:', error);
|
console.error('[voting] Kommentar nicht entfernt:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,14 +326,21 @@ db.exec(`
|
|||||||
author_id TEXT,
|
author_id TEXT,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
|
images TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at);
|
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(`
|
const insertCommentStmt = db.prepare(`
|
||||||
INSERT OR REPLACE INTO wish_comments
|
INSERT OR REPLACE INTO wish_comments
|
||||||
(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, @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 updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?');
|
||||||
const deleteCommentStmt = db.prepare('DELETE FROM wish_comments 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 saveWishComment = (c) => insertCommentStmt.run(c);
|
||||||
export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0;
|
export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0;
|
||||||
export const deleteWishComment = (messageId) => deleteCommentStmt.run(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 listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
|
||||||
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
|
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
|
||||||
|
|||||||
+2
-1
@@ -188,7 +188,8 @@ export const TEMPLATES = [
|
|||||||
id: 'wish.submitted', group: 'sonstiges', module: 'voting',
|
id: 'wish.submitted', group: 'sonstiges', module: 'voting',
|
||||||
name: 'Feature-Wunsch — Bestätigung', long: true,
|
name: 'Feature-Wunsch — Bestätigung', long: true,
|
||||||
vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
|
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',
|
id: 'wish.panel_title', group: 'sonstiges', module: 'voting',
|
||||||
|
|||||||
+20
-2
@@ -16,7 +16,7 @@ import {
|
|||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
|
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
|
||||||
wishComments,
|
wishComments, wishCommentImages,
|
||||||
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
|
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
@@ -36,8 +36,10 @@ import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
|||||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||||
import {
|
import {
|
||||||
wunschAnlegen, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI,
|
wunschAnlegen, wunschTagsAktualisieren, forumTagsAnlegen, wunschBilderDir,
|
||||||
|
STATUS_LABEL, STATUS_EMOJI,
|
||||||
} from '../bot/wishes.js';
|
} from '../bot/wishes.js';
|
||||||
|
import { bilderLoeschen } from '../bot/bilder.js';
|
||||||
import { heartbeatSerie } from '../bot/heartbeat.js';
|
import { heartbeatSerie } from '../bot/heartbeat.js';
|
||||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { sanitizeEmbed } from './api-v1.js';
|
import { sanitizeEmbed } from './api-v1.js';
|
||||||
@@ -2058,6 +2060,19 @@ ${rssItems}
|
|||||||
return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
|
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) ---
|
// --- Wünsche verwalten (Team) ---
|
||||||
|
|
||||||
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
|
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
|
||||||
@@ -2162,7 +2177,10 @@ ${rssItems}
|
|||||||
if (requireScope(request, reply, 'community')) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
const wunsch = getWish(Number(request.params.id));
|
const wunsch = getWish(Number(request.params.id));
|
||||||
if (!wunsch) return { deleted: false, wishes: topWishes(50) };
|
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);
|
deleteWish(wunsch.id);
|
||||||
|
await bilderLoeschen(wunschBilderDir, bilder);
|
||||||
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
|
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
|
||||||
return { deleted: true, wishes: topWishes(50) };
|
return { deleted: true, wishes: topWishes(50) };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ export async function startWebServer(client) {
|
|||||||
immutable: true,
|
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)) {
|
if (existsSync(frontendDist)) {
|
||||||
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
|
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
|
||||||
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
|
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
|
||||||
|
|||||||
Reference in New Issue
Block a user