Willkommens-Karte einstellbar: Farben, Bild, Texte, Live-Vorschau
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Die Karte war komplett fest verdrahtet — Farben, der Schriftzug
„WILLKOMMEN", die Zeile „Member #N", das Hintergrund-Raster. Wer etwas
ändern wollte, musste das SVG im Code anfassen.

Jetzt steht das Aussehen unter Config → Support: vier Farben, die beiden
Textzeilen, ein optionales Hintergrundbild per Adresse, der Markenname im
Hintergrund an/aus, und die Karte selbst abschaltbar (dann bleibt das
Text-Embed mit Avatar als Vorschaubild).

Daneben eine Vorschau, die beim Tippen mitrendert — sie geht über
/api/welcome-preview.png und bekommt die Werte als Query, damit man sieht,
was man einstellt, bevor gespeichert wird. Gespeichert wird davon nichts.

Die Akzentfarben greifen auf die Markenfarben zurück, solange nichts
Eigenes gesetzt ist. Wer also die Marke umfärbt, hat die Karte gleich mit.

Beim Hintergrundbild ist Vorsicht eingebaut: nur http(s), Zeitlimit,
Größenbegrenzung, und ein nicht erreichbares Bild lässt die Karte ohne
Bild rendern statt sie ausfallen zu lassen. Über einem Bild liegt ein
Schleier, sonst säuft der Text ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:41:05 +02:00
co-authored by Claude Fable 5
parent 90361fa55f
commit fb927d4399
6 changed files with 295 additions and 31 deletions
+67 -28
View File
@@ -1,10 +1,12 @@
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
// Willkommens-Karte: gerendertes PNG (SVG → sharp). Aussehen kommt aus den
// Einstellungen (Config → Support → Willkommens-Karte), die Standardwerte sind
// der Marken-Look: Avatar mit Neon-Ring, großer Schriftzug, Member-Nummer.
import sharp from 'sharp';
import { brandName } from '../runtime-settings.js';
import { brandName, welcomeCard } from '../runtime-settings.js';
const W = 900;
const H = 300;
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
/** XML-Sonderzeichen im Usernamen entschärfen */
function esc(s) {
@@ -12,43 +14,76 @@ function esc(s) {
.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
/**
* Bild holen und als Data-URI einbetten. Gibt bei jedem Problem '' zurück —
* die Karte wird dann ohne dieses Bild gerendert statt gar nicht.
*/
async function fetchDataUri(url, timeoutMs = 5000) {
if (!/^https?:\/\//i.test(String(url ?? ''))) return '';
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) return '';
const type = res.headers.get('content-type') ?? '';
if (!type.startsWith('image/')) return '';
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength > MAX_IMAGE_BYTES) return '';
return `data:${type.split(';')[0]};base64,${buf.toString('base64')}`;
} catch {
return '';
}
}
/**
* Karte rendern.
* @param {{ username: string, avatarUrl?: string, memberNumber?: number }} member
* @param {object} [override] — Aussehen für die Vorschau, sonst aus den Einstellungen
* @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 */ }
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }, override) {
const look = { ...welcomeCard(), ...(override ?? {}) };
const [avatarData, bgData] = await Promise.all([
fetchDataUri(avatarUrl),
look.image ? fetchDataUri(look.image, 6000) : Promise.resolve(''),
]);
const name = esc(username.length > 22 ? `${username.slice(0, 21)}` : username);
const number = Number(memberNumber) || 0;
const sub = esc(String(look.sub).replaceAll('{count}', number || '?').slice(0, 40));
const kicker = esc(look.kicker);
// Über einem Hintergrundbild braucht der Text einen Schleier, sonst säuft er ab
const veil = bgData
? `<rect width="${W}" height="${H}" fill="${look.background}" fill-opacity=".62"/>`
: `<rect width="${W}" height="${H}" fill="url(#grid)"/>`;
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"/>
<stop offset="0" stop-color="${look.accent}" stop-opacity="0"/>
<stop offset=".3" stop-color="${look.accent}"/>
<stop offset=".7" stop-color="${look.accent2}"/>
<stop offset="1" stop-color="${look.accent2}" 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"/>
<stop offset="0" stop-color="${look.accent}"/>
<stop offset="1" stop-color="${look.accent2}"/>
</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"/>
<path d="M 45 0 L 0 0 0 45" fill="none" stroke="${look.accent}" stroke-opacity=".06" stroke-width="1"/>
</pattern>
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
<clipPath id="card"><rect width="${W}" height="${H}"/></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 width="${W}" height="${H}" fill="${look.background}"/>
${bgData
? `<image href="${bgData}" x="0" y="0" width="${W}" height="${H}" clip-path="url(#card)" preserveAspectRatio="xMidYMid slice"/>`
: ''}
${veil}
${look.watermark
? `<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
font-size="120" fill="${look.accent}" 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)"/>
@@ -58,12 +93,16 @@ export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
: `<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>
${kicker
? `<text x="270" y="118" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="26"
letter-spacing="10" fill="#8a8378">${kicker}</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>
fill="${look.text}">${name}</text>
${sub
? `<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
fill="${look.accent}">${sub}</text>`
: ''}
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();