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
+70
View File
@@ -0,0 +1,70 @@
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
import sharp from 'sharp';
import { brandName } from '../runtime-settings.js';
const W = 900;
const H = 300;
/** XML-Sonderzeichen im Usernamen entschärfen */
function esc(s) {
return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
/**
* Karte rendern.
* @returns {Promise<Buffer>} PNG-Buffer
*/
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
// Avatar holen und als Data-URI einbetten (128px reicht für den 150px-Kreis)
let avatarData = '';
try {
const res = await fetch(avatarUrl, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
avatarData = `data:image/png;base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;
}
} catch { /* ohne Avatar rendern */ }
const name = esc(username.length > 22 ? `${username.slice(0, 21)}` : username);
const svg = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#f5c518" stop-opacity="0"/>
<stop offset=".3" stop-color="#f5c518"/>
<stop offset=".7" stop-color="#ff4d00"/>
<stop offset="1" stop-color="#ff4d00" stop-opacity="0"/>
</linearGradient>
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f5c518"/>
<stop offset="1" stop-color="#ff4d00"/>
</linearGradient>
<pattern id="grid" width="45" height="45" patternUnits="userSpaceOnUse">
<path d="M 45 0 L 0 0 0 45" fill="none" stroke="#f5c518" stroke-opacity=".06" stroke-width="1"/>
</pattern>
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="#0a0a0a"/>
<rect width="${W}" height="${H}" fill="url(#grid)"/>
<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
font-size="120" fill="#f5c518" fill-opacity=".05">${esc(brandName())}</text>
<rect x="0" y="0" width="${W}" height="3" fill="url(#line)"/>
<rect x="0" y="${H - 3}" width="${W}" height="3" fill="url(#line)"/>
<circle cx="150" cy="150" r="80" fill="none" stroke="url(#ring)" stroke-width="4"/>
${avatarData
? `<image href="${avatarData}" x="75" y="75" width="150" height="150" clip-path="url(#avatar)" preserveAspectRatio="xMidYMid slice"/>`
: `<circle cx="150" cy="150" r="75" fill="#161616"/>
<text x="150" y="172" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="64" fill="#8a8378">?</text>`}
<text x="270" y="118" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="26"
letter-spacing="10" fill="#8a8378">WILLKOMMEN</text>
<text x="270" y="182" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="52"
fill="#e8e0d0">${name}</text>
<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
fill="#f5c518">Member #${Number(memberNumber) || '?'}</text>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}