Files
d4rkbot/frontend/src/pages/Devlogs.jsx
T
D4rkst3randClaude Fable 5 676fc45bb1 Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish
- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im
  Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs
  bisherige Embed wenn das Rendering scheitert; Dockerfile installiert
  fonts-dejavu-core für die Text-Darstellung
- Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle),
  tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über
  last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am
  nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab
- Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id),
  Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert
  Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und
  Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite)
- Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-,
  Composer- und geplante Posts automatisch in Ankündigungs-Kanälen
- brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:43:15 +02:00

204 lines
8.9 KiB
React

import { useCallback, useEffect, useState } from 'react';
import { apiGet, apiDelete } from '../api.js';
import { Markdown } from '../markdown.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonCards } from '../components/Skeleton.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) {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
if (days <= 0) return 'heute';
if (days === 1) return 'gestern';
if (days < 30) return `vor ${days} Tagen`;
const months = Math.floor(days / 30);
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
*/
export 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({ me }) {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(false);
const [qInput, setQInput] = useState('');
const [q, setQ] = useState('');
const [lightbox, setLightbox] = useState(null); // { images, start }
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
useEffect(() => {
const t = setTimeout(() => {
setQ(qInput.trim());
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [qInput]);
const load = useCallback(() => {
setError(false);
const search = q ? `&q=${encodeURIComponent(q)}` : '';
apiGet(`/api/devlogs?page=${page}${search}`).then(setData).catch(() => setError(true));
}, [page, q]);
useEffect(() => {
load();
window.scrollTo(0, 0);
}, [load]);
// Admin: Devlog aus dem Archiv entfernen (löscht auch die lokalen Bilder)
async function remove(id) {
if (!window.confirm('Dieses Devlog aus dem Archiv löschen?')) return;
try {
await apiDelete(`/api/devlogs/${id}`);
load();
} catch {
window.alert('Löschen fehlgeschlagen.');
}
}
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
// Monats-Trenner in der Timeline
let lastMonth = null;
return (
<>
<section className="page-header">
<div className="page-bg-text">DEVLOG</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Archiv</div>
<h1 className="page-title">Devlog</h1>
<p className="page-subtitle">
Entwicklungs-Updates, frisch aus dem Editor automatisch archiviert.
</p>
</div>
</section>
<section className="content">
<div className="search">
<span className="search-icon"></span>
<input
type="search"
placeholder="Devlogs durchsuchen …"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
/>
{q && data && <span className="search-count">{data.total} Treffer</span>}
</div>
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.total === 0 && (
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
)}
{data?.total > 0 && (
<div className="timeline">
{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>
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog">🔗</a>
{me?.admin && (
<button
className="card-delete"
title="Aus dem Archiv löschen"
onClick={() => remove(d.message_id)}
>
</button>
)}
</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, i) => (
<a
key={src} href={src}
onClick={(e) => {
e.preventDefault();
setLightbox({ images: d.images, start: i });
}}
>
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
</a>
))}
</div>
)}
{footer && (
<div className="card-footer">
<Markdown text={footer} />
</div>
)}
</article>
</div>
);
})}
</div>
)}
{lightbox && (
<Lightbox
images={lightbox.images}
start={lightbox.start}
onClose={() => setLightbox(null)}
/>
)}
{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>
</>
);
}