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:
@@ -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 (
|
||||
<>
|
||||
<section className="page-header">
|
||||
@@ -50,19 +77,43 @@ export default function Devlogs() {
|
||||
|
||||
{data?.total > 0 && (
|
||||
<div className="timeline">
|
||||
{data.items.map((d) => (
|
||||
<article className="card" key={d.message_id}>
|
||||
<div className="card-meta">
|
||||
<span className="card-date">
|
||||
{dateFmt.format(new Date(d.posted_at))}
|
||||
</span>
|
||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||
{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 (
|
||||
<div key={d.message_id}>
|
||||
{showMonth && <div className="month-sep">{month}</div>}
|
||||
<article className="card">
|
||||
<div className="card-meta">
|
||||
<span className="card-date">
|
||||
{dateFmt.format(new Date(d.posted_at))}
|
||||
</span>
|
||||
{project && <span className="card-project">{project}</span>}
|
||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<Markdown text={body} />
|
||||
</div>
|
||||
{d.images?.length > 0 && (
|
||||
<div className={`card-images n${Math.min(d.images.length, 4)}`}>
|
||||
{d.images.map((src) => (
|
||||
<a key={src} href={src} target="_blank" rel="noreferrer">
|
||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{footer && (
|
||||
<div className="card-footer">
|
||||
<Markdown text={footer} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<Markdown text={d.content} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user