From ed69d859ca58893d7448d1153067bf5678f62f49 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 00:56:53 +0200 Subject: [PATCH] Devlog-Seite: Bildergalerie, Projekt-Chips, Monats-Trenner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/pages/Devlogs.jsx | 75 ++++++++++++++++++++++++----- frontend/src/style.css | 57 ++++++++++++++++++++++ src/bot/client.js | 4 +- src/bot/commands/devlog-backfill.js | 2 +- src/bot/devlog-archive.js | 48 ++++++++++++++++-- src/db.js | 20 ++++++-- src/web/api.js | 7 ++- src/web/server.js | 10 ++++ 8 files changed, 199 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/Devlogs.jsx b/frontend/src/pages/Devlogs.jsx index 7dce981..c2f4adf 100644 --- a/frontend/src/pages/Devlogs.jsx +++ b/frontend/src/pages/Devlogs.jsx @@ -5,6 +5,7 @@ import { Markdown } from '../markdown.jsx'; const dateFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', }); +const monthFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); /** Relative Angabe wie „vor 3 Tagen" für die Karten-Ecke */ function relative(iso) { @@ -16,6 +17,29 @@ function relative(iso) { return months === 1 ? 'vor 1 Monat' : `vor ${months} Monaten`; } +/** + * Devlog-Inhalt in Karten-Teile zerlegen: + * - Embed-Titel („Devlog — Projekt") → Projekt-Chip statt Fließtext + * - Abschließende Kursiv-Zeile („*X Commits heute …*") → abgesetzte Fußzeile + */ +function splitContent(raw) { + const blocks = raw.split(/\n{2,}/); + let project = null; + let footer = null; + + const title = blocks[0]?.match(/^Devlog\s*[—–-]\s*(.+)$/i); + if (title) { + project = title[1].trim(); + blocks.shift(); + } + const last = blocks[blocks.length - 1]; + if (blocks.length > 1 && /^\*[^*]+\*$/s.test(last?.trim() ?? '')) { + footer = last.trim().slice(1, -1); + blocks.pop(); + } + return { project, footer, body: blocks.join('\n\n') }; +} + export default function Devlogs() { const [data, setData] = useState(null); const [page, setPage] = useState(1); @@ -29,6 +53,9 @@ export default function Devlogs() { const pages = data ? Math.ceil(data.total / data.pageSize) : 0; + // Monats-Trenner in der Timeline + let lastMonth = null; + return ( <>
@@ -50,19 +77,43 @@ export default function Devlogs() { {data?.total > 0 && (
- {data.items.map((d) => ( -
-
- - {dateFmt.format(new Date(d.posted_at))} - - {relative(d.posted_at)} + {data.items.map((d) => { + const { project, footer, body } = splitContent(d.content); + const month = monthFmt.format(new Date(d.posted_at)); + const showMonth = month !== lastMonth; + lastMonth = month; + return ( +
+ {showMonth &&
{month}
} +
+
+ + {dateFmt.format(new Date(d.posted_at))} + + {project && {project}} + {relative(d.posted_at)} +
+
+ +
+ {d.images?.length > 0 && ( +
+ {d.images.map((src) => ( + + Devlog-Screenshot + + ))} +
+ )} + {footer && ( +
+ +
+ )} +
-
- -
-
- ))} + ); + })}
)} diff --git a/frontend/src/style.css b/frontend/src/style.css index eb77309..8f5a3ba 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -187,6 +187,18 @@ body::after { display: flex; flex-direction: column; gap: 1.5rem; position: relative; padding-left: 1.6rem; } +.month-sep { + font-family: var(--mono); + font-size: .68rem; letter-spacing: .3em; text-transform: uppercase; + color: var(--neon2); + margin: .5rem 0 1.25rem; + display: flex; align-items: center; gap: 1rem; +} +.month-sep::after { + content: ''; flex: 1; height: 1px; + background: linear-gradient(to right, rgba(255, 77, 0, .35), transparent); +} +.timeline > div:first-child .month-sep { margin-top: 0; } .timeline::before { content: ''; position: absolute; left: 3px; top: 10px; bottom: 10px; @@ -224,6 +236,15 @@ body::after { font-family: var(--mono); font-size: .65rem; letter-spacing: .1em; color: var(--muted2); white-space: nowrap; + margin-left: auto; +} +.card-project { + font-family: var(--mono); + font-size: .6rem; letter-spacing: .18em; text-transform: uppercase; + color: var(--neon2); + border: 1px solid rgba(255, 77, 0, .3); + padding: .1rem .5rem; + white-space: nowrap; } .card-body { color: var(--text); font-weight: 300; font-size: 1.08rem; } @@ -247,6 +268,42 @@ body::after { color: var(--success); } +/* Bildergalerie (heruntergeladene Devlog-Screenshots) */ +.card-images { + display: grid; gap: .5rem; + margin-top: .9rem; +} +.card-images.n1 { grid-template-columns: 1fr; } +.card-images.n2 { grid-template-columns: 1fr 1fr; } +.card-images.n3 { grid-template-columns: 1fr 1fr 1fr; } +.card-images.n4 { grid-template-columns: 1fr 1fr; } +.card-images a { + display: block; overflow: hidden; + border: 1px solid rgba(255, 255, 255, .08); + aspect-ratio: 16 / 9; + transition: border-color .2s; +} +.card-images a:hover { border-color: var(--neon); } +.card-images img { + width: 100%; height: 100%; object-fit: cover; display: block; + transition: transform .3s; +} +.card-images a:hover img { transform: scale(1.03); } +.card-images.n1 a { aspect-ratio: auto; max-height: 420px; } +.card-images.n1 img { object-fit: contain; background: #000; } + +/* Abgesetzte Fußzeile („X Commits heute — Details im Repo") */ +.card-footer { + margin-top: .9rem; padding-top: .7rem; + border-top: 1px solid rgba(255, 255, 255, .06); + font-family: var(--mono); + font-size: .68rem; letter-spacing: .08em; + color: var(--muted2); +} +.card-footer p { margin: 0; } +.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; } +.card-footer a:hover { color: var(--neon); } + /* ── COMMIT-LISTE ──────────────────────────────────── */ .commit-list { border: 1px solid rgba(255, 255, 255, .06); background: var(--bg2); } .commit-row { diff --git a/src/bot/client.js b/src/bot/client.js index fef4558..6cbed7a 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -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) { diff --git a/src/bot/commands/devlog-backfill.js b/src/bot/commands/devlog-backfill.js index 246a71e..53adc67 100644 --- a/src/bot/commands/devlog-backfill.js +++ b/src/bot/commands/devlog-backfill.js @@ -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; } diff --git a/src/bot/devlog-archive.js b/src/bot/devlog-archive.js index 819fce8..5837d03 100644 --- a/src/bot/devlog-archive.js +++ b/src/bot/devlog-archive.js @@ -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} 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), }); } diff --git a/src/db.js b/src/db.js index d6fdf96..3cc9b50 100644 --- a/src/db.js +++ b/src/db.js @@ -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'); diff --git a/src/web/api.js b/src/web/api.js index 2d5181f..a4c74c1 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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) diff --git a/src/web/server.js b/src/web/server.js index 40750db..67c8583 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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 });