Devlog-Seite: Bildergalerie, Projekt-Chips, Monats-Trenner

- Bild-Attachments werden beim Archivieren lokal gespeichert (Discord-CDN-URLs
  laufen ab) und unter /devlog-assets/ ausgeliefert; DB-Migration: images-Spalte
- Frontend: Bildergalerie im Grid (responsive nach Anzahl), Embed-Titel als
  Projekt-Chip, Commit-Fußzeile abgesetzt, Monats-Trenner in der Timeline
- Backfill lädt Bilder alter Devlogs nach (Dedupe verhindert Doppel-Downloads)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:56:53 +02:00
co-authored by Claude Opus 4.8
parent 4b7a93ddb1
commit ed69d859ca
8 changed files with 199 additions and 24 deletions
+2 -2
View File
@@ -31,10 +31,10 @@ export async function startBot() {
});
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
client.on(Events.MessageCreate, (message) => {
client.on(Events.MessageCreate, async (message) => {
if (message.channelId !== config.devlogChannelId) return;
try {
if (archiveDevlogMessage(message)) {
if (await archiveDevlogMessage(message)) {
console.log(`[devlog] Neues Devlog archiviert (${message.id})`);
}
} catch (error) {
+1 -1
View File
@@ -30,7 +30,7 @@ export async function execute(interaction) {
if (message.webhookId) counts.webhook++;
else if (message.author?.bot) counts.bot++;
else counts.user++;
if (archiveDevlogMessage(message)) saved++;
if (await archiveDevlogMessage(message)) saved++;
}
before = batch.last().id;
}
+44 -4
View File
@@ -1,6 +1,18 @@
// Devlog-Archivierung: Webhook-Nachrichten aus dem Devlog-Kanal in SQLite sichern.
// 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 { saveDevlog } from '../db.js';
import { mkdirSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { saveDevlog, hasDevlog } from '../db.js';
import { config } from '../config.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 });
/** Text aus einer Nachricht ziehen — Plain-Content plus Embed-Titel/-Beschreibungen */
function extractContent(message) {
@@ -15,12 +27,34 @@ function extractContent(message) {
return parts.join('\n\n');
}
/** 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;
}
/**
* Nachricht archivieren, falls sie ein Devlog ist (Webhook-Post mit Text).
* Nachricht archivieren, falls sie ein Devlog ist (Webhook-/Bot-Post mit Text).
* @param {import('discord.js').Message} message
* @returns {boolean} true, wenn neu gespeichert
* @returns {Promise<boolean>} true, wenn neu gespeichert
*/
export function archiveDevlogMessage(message) {
export async function archiveDevlogMessage(message) {
// Devlogs kommen von Webhooks oder fremden Bots/Apps (je nachdem, wie
// devlog.py postet). User-Chatter und der EcoBot selbst werden ignoriert.
const isWebhook = Boolean(message.webhookId);
@@ -31,11 +65,17 @@ export function archiveDevlogMessage(message) {
const content = extractContent(message);
if (!content) return false;
// Schon archiviert? Dann Bild-Downloads sparen (Backfill ist wiederholbar)
if (hasDevlog(message.id)) return false;
const images = await downloadImages(message);
return saveDevlog({
message_id: message.id,
channel_id: message.channelId,
content,
author_name: message.author?.username ?? null,
posted_at: message.createdAt.toISOString(),
images: JSON.stringify(images),
});
}
+16 -4
View File
@@ -35,6 +35,12 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_devlogs_posted ON devlogs (posted_at DESC);
`);
// Migration: images-Spalte (JSON-Array lokaler Dateinamen) für bestehende DBs
const devlogCols = db.prepare('PRAGMA table_info(devlogs)').all();
if (!devlogCols.some((c) => c.name === 'images')) {
db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
}
const insertCommit = db.prepare(`
INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at)
VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at)
@@ -50,19 +56,25 @@ export const saveCommits = db.transaction((commits) => {
});
const insertDevlog = db.prepare(`
INSERT OR IGNORE INTO devlogs (message_id, channel_id, content, author_name, posted_at)
VALUES (@message_id, @channel_id, @content, @author_name, @posted_at)
INSERT OR IGNORE INTO devlogs (message_id, channel_id, content, author_name, posted_at, images)
VALUES (@message_id, @channel_id, @content, @author_name, @posted_at, @images)
`);
const hasDevlogStmt = db.prepare('SELECT 1 FROM devlogs WHERE message_id = ?');
/** Devlog speichern — bereits bekannte Message-IDs werden ignoriert. Gibt true zurück, wenn neu. */
export function saveDevlog(devlog) {
return insertDevlog.run(devlog).changes > 0;
return insertDevlog.run({ images: '[]', ...devlog }).changes > 0;
}
/** true, wenn die Message schon archiviert ist (spart z. B. erneute Bild-Downloads) */
export function hasDevlog(messageId) {
return Boolean(hasDevlogStmt.get(messageId));
}
// --- Lese-Queries für die Web-API (Feature 4) ---
const selectDevlogs = db.prepare(`
SELECT message_id, content, author_name, posted_at
SELECT message_id, content, author_name, posted_at, images
FROM devlogs ORDER BY posted_at DESC LIMIT ? OFFSET ?
`);
const countDevlogsStmt = db.prepare('SELECT COUNT(*) AS n FROM devlogs');
+6 -1
View File
@@ -21,7 +21,12 @@ export function registerApiRoutes(app) {
app.get('/api/devlogs', async (request) => {
const { limit, offset, page } = paging(request);
const { items, total } = listDevlogs(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE };
// images: JSON-Spalte → fertige URLs fürs Frontend
const mapped = items.map(({ images, ...rest }) => ({
...rest,
images: JSON.parse(images || '[]').map((f) => `/devlog-assets/${f}`),
}));
return { items: mapped, total, page, pageSize: PAGE_SIZE };
});
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
+10
View File
@@ -11,6 +11,7 @@ import { saveCommits } from '../db.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js';
import { imagesDir } from '../bot/devlog-archive.js';
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
// lokal nur nach `npm run build` im frontend/-Ordner
@@ -59,6 +60,15 @@ export async function startWebServer(client) {
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
// Lokal gespeicherte Devlog-Bilder (Discord-CDN-Links laufen ab)
await app.register(fastifyStatic, {
root: imagesDir,
prefix: '/devlog-assets/',
decorateReply: false,
maxAge: '30d',
immutable: true,
});
if (existsSync(frontendDist)) {
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing)
await app.register(fastifyStatic, { root: frontendDist });