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>
This commit is contained in:
2026-07-30 22:43:15 +02:00
co-authored by Claude Fable 5
parent ef0fb066f7
commit 676fc45bb1
23 changed files with 1080 additions and 21 deletions
+94
View File
@@ -0,0 +1,94 @@
// Permalink-Seite für ein einzelnes Devlog (/devlogs/:id) — teilbar mit
// Open-Graph-Vorschau (injiziert der Server ins HTML).
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { splitContent } from './Devlogs.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
});
export default function DevlogDetail() {
const { id } = useParams();
const [item, setItem] = useState(null);
const [error, setError] = useState(false);
const [lightbox, setLightbox] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
setItem(null);
setError(false);
apiGet(`/api/devlogs/${id}`).then((d) => setItem(d.item)).catch(() => setError(true));
window.scrollTo(0, 0);
}, [id]);
async function copyLink() {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* Browser ohne Clipboard-Rechte */ }
}
const parts = item ? splitContent(item.content) : 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">// Devlog</div>
<h1 className="page-title">{item ? dateFmt.format(new Date(item.posted_at)) : 'Devlog'}</h1>
{parts?.project && <p className="page-subtitle">Projekt: {parts.project}</p>}
</div>
</section>
<section className="content">
{error && (
<p className="notice">
Devlog nicht gefunden <Link to="/devlogs" style={{ textDecoration: 'underline' }}>zurück zum Archiv</Link>.
</p>
)}
{!error && !item && <SkeletonCards n={1} />}
{item && (
<>
<article className="card">
<div className="card-body">
<Markdown text={parts.body} />
</div>
{item.images?.length > 0 && (
<div className={`card-images n${Math.min(item.images.length, 4)}`}>
{item.images.map((src, i) => (
<a key={src} href={src} onClick={(e) => { e.preventDefault(); setLightbox(i); }}>
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
</a>
))}
</div>
)}
{parts.footer && (
<div className="card-footer">
<Markdown text={parts.footer} />
</div>
)}
</article>
<div className="field-row" style={{ marginTop: '1.2rem' }}>
<Link className="btn" to="/devlogs"> Alle Devlogs</Link>
<button className="btn" onClick={copyLink}>{copied ? '✓ Link kopiert' : '🔗 Link kopieren'}</button>
</div>
{lightbox !== null && (
<Lightbox images={item.images} start={lightbox} onClose={() => setLightbox(null)} />
)}
</>
)}
</section>
</>
);
}