Community-Paket: Playtester-Programm, Starboard, Screenshot-Galerie
- Playtester: /playtester-setup postet Bewerbungs-Embed mit 🧪-Button; Toggle vergibt Rolle + DB-Eintrag; Liste + Rollen-Auswahl auf der Setup-Seite - Starboard: ab konfigurierbarem ⭐-Schwellwert Repost als Embed in den Best-of-Kanal (Dedupe, kein Selbst-Boarding); neue Intents/Partials für Reaktionen - Galerie: Bilder aus dem Screenshot-Kanal lokal archiviert (live + /galerie-backfill, Delete-Sync, Bot reagiert mit ⭐) → öffentliche /galerie-Seite mit Hover-Grid - Setup-Seite: Sektion // Community (Rolle, Starboard-Kanal + Schwellwert, Screenshot-Kanal) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,17 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 📊 **Wochen-Rückblick** | Sonntags 20:00 automatisch: Commits pro Tag als Balken, Devlogs, Top-Projekte |
|
| 📊 **Wochen-Rückblick** | Sonntags 20:00 automatisch: Commits pro Tag als Balken, Devlogs, Top-Projekte |
|
||||||
| 🐛 **`/bug`** | Member melden Bugs → Gitea-Issue (inkl. Screenshot-Upload); Issue geschlossen → DM an den Reporter |
|
| 🐛 **`/bug`** | Member melden Bugs → Gitea-Issue (inkl. Screenshot-Upload); Issue geschlossen → DM an den Reporter |
|
||||||
| 🚨 **Watchdog** | Prüft eigene Dienste alle 2 min, DM-Alarm bei Ausfall + Entwarnung |
|
| 🚨 **Watchdog** | Prüft eigene Dienste alle 2 min, DM-Alarm bei Ausfall + Entwarnung |
|
||||||
|
| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste (Setup-Seite); Alpha-Keys später per DM über die API |
|
||||||
|
| ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal |
|
||||||
|
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
|
||||||
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
||||||
|
|
||||||
### Webinterface (`bot.d4rkst3r.de`)
|
### Webinterface (`bot.d4rkst3r.de`)
|
||||||
| Seite | Zugriff | Inhalt |
|
| Seite | Zugriff | Inhalt |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** |
|
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** |
|
||||||
|
| `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken |
|
||||||
|
| `/galerie` | öffentlich | Community-Screenshots aus dem Discord |
|
||||||
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
||||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||||
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiGet } from './api.js';
|
|||||||
import Devlogs from './pages/Devlogs.jsx';
|
import Devlogs from './pages/Devlogs.jsx';
|
||||||
import Changelog from './pages/Changelog.jsx';
|
import Changelog from './pages/Changelog.jsx';
|
||||||
import Roadmap from './pages/Roadmap.jsx';
|
import Roadmap from './pages/Roadmap.jsx';
|
||||||
|
import Galerie from './pages/Galerie.jsx';
|
||||||
import Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export default function App() {
|
|||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
<NavLink to="/devlogs">Devlog</NavLink>
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||||
|
<NavLink to="/galerie">Galerie</NavLink>
|
||||||
<NavLink to="/changelog">Changelog</NavLink>
|
<NavLink to="/changelog">Changelog</NavLink>
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||||
@@ -97,6 +99,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<Route path="/roadmap" element={<Roadmap />} />
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
|
<Route path="/galerie" element={<Galerie />} />
|
||||||
<Route path="/changelog" element={<Changelog />} />
|
<Route path="/changelog" element={<Changelog />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
<Route path="/settings" element={<Settings me={me} />} />
|
<Route path="/settings" element={<Settings me={me} />} />
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiGet } from '../api.js';
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
|
export default function Galerie() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(false);
|
||||||
|
apiGet(`/api/gallery?page=${page}`).then(setData).catch(() => setError(true));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
|
||||||
|
const shots = (data?.items ?? []).flatMap((item) =>
|
||||||
|
item.images.map((src) => ({ src, author: item.author_name, date: item.posted_at, key: src }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">GALERIE</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// Community</div>
|
||||||
|
<h1 className="page-title">Galerie</h1>
|
||||||
|
<p className="page-subtitle">Screenshots aus der Community — direkt aus dem Discord.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content">
|
||||||
|
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
|
||||||
|
{!error && !data && <p className="notice">Lade …</p>}
|
||||||
|
{data?.total === 0 && <p className="notice">Noch keine Screenshots — poste welche im Discord!</p>}
|
||||||
|
|
||||||
|
<div className="gallery-grid">
|
||||||
|
{shots.map((s) => (
|
||||||
|
<a className="shot" href={s.src} target="_blank" rel="noreferrer" key={s.key}>
|
||||||
|
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
|
||||||
|
<span className="shot-meta">
|
||||||
|
<span className="shot-author">{s.author}</span>
|
||||||
|
<span className="shot-date">{dateFmt.format(new Date(s.date))}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="pager">
|
||||||
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
← Neuere
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
|
Ältere →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export default function Settings({ me }) {
|
|||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [feedback, setFeedback] = useState(null);
|
const [feedback, setFeedback] = useState(null);
|
||||||
|
// Community
|
||||||
|
const [playtesters, setPlaytesters] = useState([]);
|
||||||
// API-Keys
|
// API-Keys
|
||||||
const [keys, setKeys] = useState([]);
|
const [keys, setKeys] = useState([]);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
@@ -41,6 +43,7 @@ export default function Settings({ me }) {
|
|||||||
})
|
})
|
||||||
.catch((e) => setError(e.status));
|
.catch((e) => setError(e.status));
|
||||||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||||||
|
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||||||
}, [me.admin]);
|
}, [me.admin]);
|
||||||
|
|
||||||
async function createKey() {
|
async function createKey() {
|
||||||
@@ -295,6 +298,65 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Community */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Community</h2>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
|
||||||
|
<select
|
||||||
|
value={form.playtester_role_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, playtester_role_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{(data.roles ?? []).map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>@{r.name} — {r.guild}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{playtesters.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginTop: '.8rem' }}>
|
||||||
|
{playtesters.map((p) => (
|
||||||
|
<div className="key-row" key={p.user_id}>
|
||||||
|
<span className="key-name">{p.username}</span>
|
||||||
|
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier; leer = aus</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<select
|
||||||
|
value={form.starboard_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
style={{ width: '7rem', flex: 'none' }}
|
||||||
|
title="Schwellwert (Anzahl ⭐)"
|
||||||
|
value={form.starboard_threshold}
|
||||||
|
onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Screenshot-Kanal <span className="field-hint">Bilder landen in der öffentlichen Galerie; Historie: /galerie-backfill</span></label>
|
||||||
|
<select
|
||||||
|
value={form.screenshot_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, screenshot_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Backups */}
|
{/* Backups */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Backups</h2>
|
<h2 className="settings-title">// Backups</h2>
|
||||||
|
|||||||
@@ -398,6 +398,37 @@ body::after {
|
|||||||
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
||||||
.card-footer a:hover { color: var(--neon); }
|
.card-footer a:hover { color: var(--neon); }
|
||||||
|
|
||||||
|
/* ── GALERIE ───────────────────────────────────────── */
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.shot {
|
||||||
|
position: relative; display: block; overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .08);
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.shot:hover { border-color: var(--neon); }
|
||||||
|
.shot img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
transition: transform .3s;
|
||||||
|
}
|
||||||
|
.shot:hover img { transform: scale(1.04); }
|
||||||
|
.shot-meta {
|
||||||
|
position: absolute; inset: auto 0 0 0;
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, .85), transparent);
|
||||||
|
font-family: var(--mono); font-size: .62rem; letter-spacing: .1em;
|
||||||
|
opacity: 0; transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.shot:hover .shot-meta { opacity: 1; }
|
||||||
|
.shot-author { color: var(--neon); }
|
||||||
|
.shot-date { color: var(--muted); }
|
||||||
|
|
||||||
/* ── ROADMAP ───────────────────────────────────────── */
|
/* ── ROADMAP ───────────────────────────────────────── */
|
||||||
.roadmap-group { margin-bottom: 2.25rem; }
|
.roadmap-group { margin-bottom: 2.25rem; }
|
||||||
.roadmap-group .settings-title { margin-bottom: 1rem; }
|
.roadmap-group .settings-title { margin-bottom: 1rem; }
|
||||||
|
|||||||
+41
-4
@@ -1,14 +1,18 @@
|
|||||||
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
||||||
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
|
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { devlogChannelId, devlogPingRoleId } from '../runtime-settings.js';
|
import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-settings.js';
|
||||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
|
import { registerCommunityListeners } from './community.js';
|
||||||
|
import { addPlaytester, removePlaytester } from '../db.js';
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
import * as bug from './commands/bug.js';
|
import * as bug from './commands/bug.js';
|
||||||
|
import * as playtesterSetup from './commands/playtester-setup.js';
|
||||||
|
import * as galerieBackfill from './commands/galerie-backfill.js';
|
||||||
|
|
||||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||||
const commandModules = [ping, devlogBackfill, bug];
|
const commandModules = [ping, devlogBackfill, bug, playtesterSetup, galerieBackfill];
|
||||||
|
|
||||||
export async function startBot() {
|
export async function startBot() {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
@@ -18,9 +22,11 @@ export async function startBot() {
|
|||||||
// (erfordert aktivierten "Message Content Intent" im Developer Portal!)
|
// (erfordert aktivierten "Message Content Intent" im Developer Portal!)
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
|
// Für das Starboard (⭐-Reaktionen)
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
],
|
],
|
||||||
// Partials, damit MessageDelete auch für ungecachte (ältere) Nachrichten feuert
|
// Partials, damit Delete/Reaction-Events auch für ungecachte Nachrichten feuern
|
||||||
partials: [Partials.Message],
|
partials: [Partials.Message, Partials.Reaction],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
||||||
@@ -91,6 +97,35 @@ export async function startBot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧪-Button: Playtester-Rolle + Liste togglen
|
||||||
|
if (interaction.isButton() && interaction.customId === 'playtester_toggle') {
|
||||||
|
const roleId = playtesterRoleId();
|
||||||
|
try {
|
||||||
|
if (!roleId || !interaction.inGuild()) throw new Error('Keine Playtester-Rolle konfiguriert');
|
||||||
|
const member = interaction.member;
|
||||||
|
const hasRole = member.roles.cache.has(roleId);
|
||||||
|
if (hasRole) {
|
||||||
|
await member.roles.remove(roleId);
|
||||||
|
removePlaytester(interaction.user.id);
|
||||||
|
} else {
|
||||||
|
await member.roles.add(roleId);
|
||||||
|
addPlaytester(interaction.user.id, interaction.user.username);
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: hasRole
|
||||||
|
? '🚪 Von der Playtester-Liste **abgemeldet**.'
|
||||||
|
: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[playtester] Toggle fehlgeschlagen:', error);
|
||||||
|
await interaction
|
||||||
|
.reply({ content: '❌ Konnte die Rolle nicht ändern — Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
const command = client.commands.get(interaction.commandName);
|
const command = client.commands.get(interaction.commandName);
|
||||||
@@ -109,6 +144,8 @@ export async function startBot() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerCommunityListeners(client);
|
||||||
|
|
||||||
await client.login(config.discordToken);
|
await client.login(config.discordToken);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// /galerie-backfill — bestehende Screenshots aus dem Kanal nacharchivieren (Admin)
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
|
||||||
|
import { screenshotChannelId } from '../../runtime-settings.js';
|
||||||
|
import { archiveGalleryMessage } from '../community.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('galerie-backfill')
|
||||||
|
.setDescription('Archiviert alle bisherigen Screenshots aus dem Galerie-Kanal')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const channelId = screenshotChannelId();
|
||||||
|
if (!channelId) {
|
||||||
|
await interaction.editReply('❌ Erst auf der Setup-Seite einen Screenshot-Kanal wählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = await interaction.client.channels.fetch(channelId);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.editReply('❌ Screenshot-Kanal nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanned = 0;
|
||||||
|
let saved = 0;
|
||||||
|
let before;
|
||||||
|
for (;;) {
|
||||||
|
const batch = await channel.messages.fetch({ limit: 100, before });
|
||||||
|
if (batch.size === 0) break;
|
||||||
|
for (const message of batch.values()) {
|
||||||
|
scanned++;
|
||||||
|
if (await archiveGalleryMessage(message)) saved++;
|
||||||
|
}
|
||||||
|
before = batch.last().id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply(
|
||||||
|
`✅ Galerie-Backfill fertig: ${scanned} Nachrichten gescannt, **${saved} Screenshots archiviert**.`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// /playtester-setup — postet das Bewerbungs-Embed mit Button in den aktuellen Kanal (Admin)
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
|
||||||
|
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
|
import { playtesterRoleId } from '../../runtime-settings.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('playtester-setup')
|
||||||
|
.setDescription('Postet das Playtester-Bewerbungs-Embed in diesen Kanal')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
if (!playtesterRoleId()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ Erst auf der Setup-Seite eine Playtester-Rolle wählen.',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xf5c518)
|
||||||
|
.setTitle('🧪 Playtester werden')
|
||||||
|
.setDescription(
|
||||||
|
'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback ' +
|
||||||
|
'die Entwicklung mitgestalten?\n\n' +
|
||||||
|
'Ein Klick auf den Button setzt dich auf die Playtester-Liste — ' +
|
||||||
|
'sobald die Alpha startet, bekommst du deinen Zugang direkt per DM. ' +
|
||||||
|
'Abmelden geht jederzeit mit einem zweiten Klick.'
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'D4RKST3R // PLAYTEST' });
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('playtester_toggle')
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setLabel('Als Playtester bewerben')
|
||||||
|
.setEmoji('🧪')
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.channel.send({ embeds: [embed], components: [row] });
|
||||||
|
await interaction.reply({ content: '✅ Bewerbungs-Post erstellt.', flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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,
|
||||||
|
saveGalleryItem, hasGalleryItem, deleteGalleryItem,
|
||||||
|
} from '../db.js';
|
||||||
|
import { starboardChannelId, starboardThreshold, screenshotChannelId } from '../runtime-settings.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const BRAND_YELLOW = 0xf5c518;
|
||||||
|
const MAX_GALLERY_IMAGES = 4;
|
||||||
|
|
||||||
|
// Galerie-Bilder liegen neben der SQLite: data/gallery/
|
||||||
|
export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery');
|
||||||
|
mkdirSync(galleryDir, { recursive: true });
|
||||||
|
|
||||||
|
/* ── Screenshot-Galerie ────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachricht aus dem Screenshot-Kanal archivieren (Bilder lokal speichern).
|
||||||
|
* @returns {Promise<boolean>} true, wenn neu gespeichert
|
||||||
|
*/
|
||||||
|
export async function archiveGalleryMessage(message) {
|
||||||
|
if (hasGalleryItem(message.id)) return false;
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
for (const att of message.attachments?.values?.() ?? []) {
|
||||||
|
if (files.length >= MAX_GALLERY_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;
|
||||||
|
|
||||||
|
return saveGalleryItem({
|
||||||
|
message_id: message.id,
|
||||||
|
author_name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
|
||||||
|
images: JSON.stringify(files),
|
||||||
|
posted_at: message.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 });
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Starboard ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function handleStarReaction(reaction) {
|
||||||
|
const boardChannelId = starboardChannelId();
|
||||||
|
if (!boardChannelId) return;
|
||||||
|
if (reaction.emoji.name !== '⭐') return;
|
||||||
|
|
||||||
|
if (reaction.partial) await reaction.fetch();
|
||||||
|
let message = reaction.message;
|
||||||
|
if (message.partial) message = await message.fetch();
|
||||||
|
|
||||||
|
// Nicht das Starboard selbst boarden, Dedupe, Schwellwert
|
||||||
|
if (message.channelId === boardChannelId) return;
|
||||||
|
if (hasStarboard(message.id)) return;
|
||||||
|
if ((reaction.count ?? 0) < starboardThreshold()) return;
|
||||||
|
|
||||||
|
const board = await message.client.channels.fetch(boardChannelId);
|
||||||
|
if (!board?.isTextBased()) return;
|
||||||
|
|
||||||
|
const image = [...message.attachments.values()].find((a) =>
|
||||||
|
a.contentType?.startsWith('image/')
|
||||||
|
);
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setAuthor({
|
||||||
|
name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
|
||||||
|
iconURL: message.author?.displayAvatarURL?.() ?? undefined,
|
||||||
|
})
|
||||||
|
.setDescription(
|
||||||
|
`${(message.content || '').slice(0, 1800) || '*[Bild]*'}\n\n[→ Zum Original](${message.url})`
|
||||||
|
)
|
||||||
|
.setFooter({ text: `⭐ ${reaction.count} • #${message.channel?.name ?? ''}` })
|
||||||
|
.setTimestamp(message.createdAt);
|
||||||
|
if (image) embed.setImage(image.url);
|
||||||
|
|
||||||
|
const boardMessage = await board.send({ embeds: [embed] });
|
||||||
|
saveStarboard(message.id, boardMessage.id);
|
||||||
|
console.log(`[starboard] ${message.id} geboardet (${reaction.count} ⭐)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Listener-Registrierung ────────────────────────── */
|
||||||
|
|
||||||
|
export function registerCommunityListeners(client) {
|
||||||
|
client.on(Events.MessageReactionAdd, async (reaction) => {
|
||||||
|
try {
|
||||||
|
await handleStarReaction(reaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[starboard] Fehler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
if (message.channelId !== screenshotChannelId()) return;
|
||||||
|
try {
|
||||||
|
if (await archiveGalleryMessage(message)) {
|
||||||
|
console.log(`[galerie] Screenshot archiviert (${message.id})`);
|
||||||
|
await message.react('⭐').catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[galerie] Archivierung fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
|
try {
|
||||||
|
if (await removeGalleryMessage(message.id)) {
|
||||||
|
console.log(`[galerie] Eintrag entfernt (${message.id})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[galerie] Löschung fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -114,6 +114,70 @@ export function takeBugReport(repo, issueNumber) {
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community: Playtester, Starboard, Screenshot-Galerie
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playtesters (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS starboard (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
board_message_id TEXT,
|
||||||
|
starred_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS gallery (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
author_name TEXT,
|
||||||
|
images TEXT NOT NULL,
|
||||||
|
posted_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gallery_posted ON gallery (posted_at DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertPlaytester = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO playtesters (user_id, username) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?');
|
||||||
|
const listPlaytestersStmt = db.prepare(
|
||||||
|
'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at'
|
||||||
|
);
|
||||||
|
export const addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0;
|
||||||
|
export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0;
|
||||||
|
export const listPlaytesters = () => listPlaytestersStmt.all();
|
||||||
|
|
||||||
|
const hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?');
|
||||||
|
const insertStarboard = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO starboard (message_id, board_message_id) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
export const hasStarboard = (id) => Boolean(hasStarboardStmt.get(id));
|
||||||
|
export const saveStarboard = (id, boardId) => insertStarboard.run(id, boardId);
|
||||||
|
|
||||||
|
const insertGallery = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO gallery (message_id, author_name, images, posted_at)
|
||||||
|
VALUES (@message_id, @author_name, @images, @posted_at)
|
||||||
|
`);
|
||||||
|
const hasGalleryStmt = db.prepare('SELECT 1 FROM gallery WHERE message_id = ?');
|
||||||
|
const getGalleryImagesStmt = db.prepare('SELECT images FROM gallery WHERE message_id = ?');
|
||||||
|
const deleteGalleryStmt = db.prepare('DELETE FROM gallery WHERE message_id = ?');
|
||||||
|
const listGalleryStmt = db.prepare(`
|
||||||
|
SELECT message_id, author_name, images, posted_at
|
||||||
|
FROM gallery ORDER BY posted_at DESC LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
const countGalleryStmt = db.prepare('SELECT count(*) AS n FROM gallery');
|
||||||
|
export const saveGalleryItem = (item) => insertGallery.run(item).changes > 0;
|
||||||
|
export const hasGalleryItem = (id) => Boolean(hasGalleryStmt.get(id));
|
||||||
|
export function deleteGalleryItem(id) {
|
||||||
|
const row = getGalleryImagesStmt.get(id);
|
||||||
|
if (!row) return { deleted: false, images: [] };
|
||||||
|
deleteGalleryStmt.run(id);
|
||||||
|
return { deleted: true, images: JSON.parse(row.images || '[]') };
|
||||||
|
}
|
||||||
|
export const listGallery = (limit, offset) => ({
|
||||||
|
items: listGalleryStmt.all(limit, offset),
|
||||||
|
total: countGalleryStmt.get().n,
|
||||||
|
});
|
||||||
|
|
||||||
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
|
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ export function devlogThreadsEnabled() {
|
|||||||
return getSetting('devlog_threads_enabled') !== '0';
|
return getSetting('devlog_threads_enabled') !== '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Playtester-Rolle (Bewerbungs-Button) — leer = Feature aus */
|
||||||
|
export function playtesterRoleId() {
|
||||||
|
return getSetting('playtester_role_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starboard-Kanal — leer = Feature aus */
|
||||||
|
export function starboardChannelId() {
|
||||||
|
return getSetting('starboard_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ab wie vielen ⭐ landet eine Nachricht im Starboard (Default: 3) */
|
||||||
|
export function starboardThreshold() {
|
||||||
|
const n = Number(getSetting('starboard_threshold'));
|
||||||
|
return Number.isInteger(n) && n >= 1 ? n : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screenshot-Kanal für die Galerie — leer = Feature aus */
|
||||||
|
export function screenshotChannelId() {
|
||||||
|
return getSetting('screenshot_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ziel-Repo für /bug-Reports (Default: EcoGame) */
|
/** Ziel-Repo für /bug-Reports (Default: EcoGame) */
|
||||||
export function bugReportRepo() {
|
export function bugReportRepo() {
|
||||||
return getSetting('bug_report_repo') || 'D4rkst3r/EcoGame';
|
return getSetting('bug_report_repo') || 'D4rkst3r/EcoGame';
|
||||||
|
|||||||
+40
-8
@@ -3,6 +3,7 @@ import { EmbedBuilder } from 'discord.js';
|
|||||||
import {
|
import {
|
||||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||||
|
listGallery, listPlaytesters,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
@@ -53,6 +54,23 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { items, total, page, pageSize: PAGE_SIZE };
|
return { items, total, page, pageSize: PAGE_SIZE };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Screenshot-Galerie — öffentlich
|
||||||
|
app.get('/api/gallery', async (request) => {
|
||||||
|
const { limit, offset, page } = paging(request);
|
||||||
|
const { items, total } = listGallery(limit, offset);
|
||||||
|
const mapped = items.map(({ images, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
images: JSON.parse(images || '[]').map((f) => `/gallery-assets/${f}`),
|
||||||
|
}));
|
||||||
|
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playtester-Liste — Admin
|
||||||
|
app.get('/api/playtesters', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { playtesters: listPlaytesters() };
|
||||||
|
});
|
||||||
|
|
||||||
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
||||||
let roadmapCache = { at: 0, repo: null, data: null };
|
let roadmapCache = { at: 0, repo: null, data: null };
|
||||||
app.get('/api/roadmap', async (request, reply) => {
|
app.get('/api/roadmap', async (request, reply) => {
|
||||||
@@ -181,6 +199,10 @@ ${rssItems}
|
|||||||
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
||||||
backup_enabled: getSetting('backup_enabled') !== '0',
|
backup_enabled: getSetting('backup_enabled') !== '0',
|
||||||
backup_channel_id: getSetting('backup_channel_id') ?? '',
|
backup_channel_id: getSetting('backup_channel_id') ?? '',
|
||||||
|
playtester_role_id: getSetting('playtester_role_id') ?? '',
|
||||||
|
starboard_channel_id: getSetting('starboard_channel_id') ?? '',
|
||||||
|
starboard_threshold: Number(getSetting('starboard_threshold')) || 3,
|
||||||
|
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +230,12 @@ ${rssItems}
|
|||||||
|
|
||||||
const body = request.body ?? {};
|
const body = request.body ?? {};
|
||||||
|
|
||||||
// Kanäle: müssen existierende Textkanäle sein (release/backup dürfen leer sein = aus)
|
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
||||||
for (const key of ['commit_channel_id', 'devlog_channel_id', 'release_channel_id', 'backup_channel_id']) {
|
const OPTIONAL_CHANNELS = ['release_channel_id', 'backup_channel_id', 'starboard_channel_id', 'screenshot_channel_id'];
|
||||||
|
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||||
if (body[key] === undefined) continue;
|
if (body[key] === undefined) continue;
|
||||||
const value = String(body[key]);
|
const value = String(body[key]);
|
||||||
if (value === '' && ['release_channel_id', 'backup_channel_id'].includes(key)) {
|
if (value === '' && OPTIONAL_CHANNELS.includes(key)) {
|
||||||
setSetting(key, '');
|
setSetting(key, '');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -222,18 +245,27 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
setSetting(key, value);
|
setSetting(key, value);
|
||||||
}
|
}
|
||||||
// Ping-Rolle: muss existieren ('' = kein Ping)
|
// Rollen: müssen existieren ('' = Feature aus)
|
||||||
if (body.devlog_ping_role_id !== undefined) {
|
for (const key of ['devlog_ping_role_id', 'playtester_role_id']) {
|
||||||
const value = String(body.devlog_ping_role_id);
|
if (body[key] === undefined) continue;
|
||||||
|
const value = String(body[key]);
|
||||||
if (value !== '') {
|
if (value !== '') {
|
||||||
const exists = [...client.guilds.cache.values()].some((g) =>
|
const exists = [...client.guilds.cache.values()].some((g) =>
|
||||||
g.roles?.cache?.has?.(value)
|
g.roles?.cache?.has?.(value)
|
||||||
);
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return reply.code(400).send({ error: 'devlog_ping_role_id: Rolle nicht gefunden' });
|
return reply.code(400).send({ error: `${key}: Rolle nicht gefunden` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSetting('devlog_ping_role_id', value);
|
setSetting(key, value);
|
||||||
|
}
|
||||||
|
// Starboard-Schwellwert: 1–50
|
||||||
|
if (body.starboard_threshold !== undefined) {
|
||||||
|
const n = Number(body.starboard_threshold);
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 50) {
|
||||||
|
return reply.code(400).send({ error: 'starboard_threshold: 1–50' });
|
||||||
|
}
|
||||||
|
setSetting('starboard_threshold', String(n));
|
||||||
}
|
}
|
||||||
if (body.commit_feed_enabled !== undefined) {
|
if (body.commit_feed_enabled !== undefined) {
|
||||||
setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0');
|
setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0');
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export async function startWebServer(client) {
|
|||||||
maxAge: '30d',
|
maxAge: '30d',
|
||||||
immutable: true,
|
immutable: true,
|
||||||
});
|
});
|
||||||
|
// Screenshot-Galerie-Bilder
|
||||||
|
const { galleryDir } = await import('../bot/community.js');
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: galleryDir,
|
||||||
|
prefix: '/gallery-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)
|
||||||
|
|||||||
Reference in New Issue
Block a user