diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index cf3c9dc..6a73e33 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -305,6 +305,31 @@ export default function Settings({ me }) {
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
}, [data]);
+ // Vorschau der Willkommens-Karte. Verzögert, damit nicht jeder Tastendruck
+ // ein Rendering auslöst — die Karte wird serverseitig mit sharp gebaut.
+ const [welcomePreviewUrl, setWelcomePreviewUrl] = useState('/api/welcome-preview.png');
+ useEffect(() => {
+ if (!form) return undefined;
+ const timer = setTimeout(() => {
+ const q = new URLSearchParams({
+ background: form.welcome_card_bg ?? '',
+ accent: form.welcome_card_accent ?? '',
+ accent2: form.welcome_card_accent2 ?? '',
+ text: form.welcome_card_text ?? '',
+ kicker: form.welcome_card_kicker ?? '',
+ sub: form.welcome_card_sub ?? '',
+ image: form.welcome_card_image ?? '',
+ watermark: form.welcome_card_watermark ? '1' : '0',
+ });
+ setWelcomePreviewUrl(`/api/welcome-preview.png?${q}`);
+ }, 450);
+ return () => clearTimeout(timer);
+ }, [
+ form?.welcome_card_bg, form?.welcome_card_accent, form?.welcome_card_accent2,
+ form?.welcome_card_text, form?.welcome_card_kicker, form?.welcome_card_sub,
+ form?.welcome_card_image, form?.welcome_card_watermark,
+ ]);
+
// Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln
useEffect(() => {
const onHash = () => {
@@ -1244,6 +1269,7 @@ export default function Settings({ me }) {
);
const tabSupport = (
+ <>
// Moderation & Kontakt
@@ -1275,6 +1301,88 @@ export default function Settings({ me }) {
+
+
+
// Willkommens-Karte
+
+ Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
+ zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
+
+
+
+
+ setForm({ ...form, welcome_card_enabled: e.target.checked })}
+ />
+ Karte rendern
+ aus = nur das Text-Embed mit Avatar als Vorschaubild
+
+
+
+ {form.welcome_card_enabled && (
+ <>
+
+
+
+ {[
+ ['welcome_card_bg', 'Hintergrund'],
+ ['welcome_card_accent', 'Akzent 1'],
+ ['welcome_card_accent2', 'Akzent 2'],
+ ['welcome_card_text', 'Name'],
+ ].map(([key, label]) => (
+
+ setForm({ ...form, [key]: e.target.value })}
+ />
+ {label}
+
+ ))}
+
+
+
+ Kleine Zeile über dem Namen — leer = weglassen
+ setForm({ ...form, welcome_card_kicker: e.target.value })}
+ />
+
+
+ Zeile darunter {'{count}'} wird zur Mitglieds-Nummer — leer = weglassen
+ setForm({ ...form, welcome_card_sub: e.target.value })}
+ />
+
+
+ Hintergrundbild https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster
+ setForm({ ...form, welcome_card_image: e.target.value })}
+ />
+
+
+
+ setForm({ ...form, welcome_card_watermark: e.target.checked })}
+ />
+ Markenname im Hintergrund
+ großer, schwach sichtbarer Schriftzug hinten rechts
+
+
+ >
+ )}
+
+ >
);
const tabBewerbungen = (
diff --git a/frontend/src/style.css b/frontend/src/style.css
index cb8a3e8..a770df0 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1109,6 +1109,22 @@ label.toggle {
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
}
+/* Willkommens-Karte: Vorschau + Farbwähler */
+.welcome-preview {
+ display: block; width: 100%; max-width: 900px;
+ aspect-ratio: 3 / 1;
+ border: 1px solid var(--border); border-radius: 10px;
+ background: var(--bg3);
+ margin-bottom: 1.1rem;
+}
+.wc-colors { display: flex; flex-wrap: wrap; gap: 1.2rem; margin-bottom: 1.2rem; }
+.wc-color { display: flex; align-items: center; gap: .55rem; cursor: pointer; }
+.wc-color > span {
+ font-family: var(--mono); font-size: .64rem;
+ letter-spacing: .14em; text-transform: uppercase; color: var(--muted);
+}
+.wc-color:hover > span { color: var(--text); }
+
/* Branding-Tab: Farb- und Datei-Inputs */
.color-pick {
width: 3.2rem; height: 2.4rem; flex: none;
diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js
index 2ee08cc..2f79120 100644
--- a/src/bot/mod-tools.js
+++ b/src/bot/mod-tools.js
@@ -1,7 +1,7 @@
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
-import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
+import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
@@ -104,6 +104,7 @@ async function handleMemberAdd(member) {
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
let files = [];
try {
+ if (!welcomeCard().enabled) throw new Error('Karte abgeschaltet');
const { buildWelcomeCard } = await import('./welcome-card.js');
const png = await buildWelcomeCard({
username: member.displayName,
@@ -114,7 +115,10 @@ async function handleMemberAdd(member) {
embed.setImage('attachment://welcome.png');
} catch (error) {
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
- console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
+ // Abgeschaltet ist kein Fehler — nur echte Pannen sollen im Log auffallen
+ if (welcomeCard().enabled) {
+ console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
+ }
}
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
diff --git a/src/bot/welcome-card.js b/src/bot/welcome-card.js
index 785a82a..bece5fa 100644
--- a/src/bot/welcome-card.js
+++ b/src/bot/welcome-card.js
@@ -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('"', '"').replaceAll("'", ''');
}
+/**
+ * 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} 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
+ ? ` `
+ : ` `;
+
const svg = `
-
-
-
-
+
+
+
+
-
-
+
+
-
+
+
-
-
- ${esc(brandName())}
+
+ ${bgData
+ ? ` `
+ : ''}
+ ${veil}
+ ${look.watermark
+ ? `${esc(brandName())} `
+ : ''}
@@ -58,12 +93,16 @@ export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
: `
? `}
- WILLKOMMEN
+ ${kicker
+ ? `${kicker} `
+ : ''}
${name}
- Member #${Number(memberNumber) || '?'}
+ fill="${look.text}">${name}
+ ${sub
+ ? `${sub} `
+ : ''}
`;
return sharp(Buffer.from(svg)).png().toBuffer();
diff --git a/src/runtime-settings.js b/src/runtime-settings.js
index 4af4a9d..c774de9 100644
--- a/src/runtime-settings.js
+++ b/src/runtime-settings.js
@@ -229,6 +229,29 @@ export function welcomeChannelId() {
return getSetting('welcome_channel_id') || null;
}
+/**
+ * Aussehen der Willkommens-Karte. Die Standardwerte sind der Marken-Look;
+ * die Marken-Farben ziehen automatisch nach, solange nichts eigenes gesetzt ist.
+ */
+export function welcomeCard() {
+ const hex = (key, fallback) => {
+ const value = String(getSetting(key) ?? '').trim();
+ return /^#[0-9a-fA-F]{6}$/.test(value) ? value : fallback;
+ };
+ const brand = (n) => `#${n.toString(16).padStart(6, '0')}`;
+ return {
+ enabled: getSetting('welcome_card_enabled') !== '0',
+ background: hex('welcome_card_bg', '#0a0a0a'),
+ accent: hex('welcome_card_accent', brand(brandColor())),
+ accent2: hex('welcome_card_accent2', brand(brandColor2())),
+ text: hex('welcome_card_text', '#e8e0d0'),
+ kicker: (getSetting('welcome_card_kicker') ?? 'WILLKOMMEN').slice(0, 24),
+ sub: getSetting('welcome_card_sub') ?? 'Member #{count}',
+ image: String(getSetting('welcome_card_image') ?? '').trim(),
+ watermark: getSetting('welcome_card_watermark') !== '0',
+ };
+}
+
/** Mod-Log-Kanal (privat!) — leer = Feature aus */
export function modlogChannelId() {
return getSetting('modlog_channel_id') || null;
diff --git a/src/web/api.js b/src/web/api.js
index c836bb5..58ecf11 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -29,7 +29,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
-import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl } from '../runtime-settings.js';
+import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard } from '../runtime-settings.js';
import { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
@@ -185,6 +185,38 @@ export function registerApiRoutes(app, client) {
return { ok: true, modules: moduleStates() };
});
+ // Vorschau der Willkommens-Karte. Nimmt das Aussehen als Query entgegen,
+ // damit man im Panel sieht, was man einstellt, bevor gespeichert wird.
+ app.get('/api/welcome-preview.png', async (request, reply) => {
+ if (requireScope(request, reply, 'settings')) return;
+ const q = request.query ?? {};
+ const hex = (v) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '')) ? String(v).toLowerCase() : undefined);
+ // Nur bekannte Felder übernehmen — der Rest kommt aus den Einstellungen
+ const override = {};
+ for (const key of ['background', 'accent', 'accent2', 'text']) {
+ const value = hex(q[key]);
+ if (value) override[key] = value;
+ }
+ if (q.kicker !== undefined) override.kicker = String(q.kicker).slice(0, 24);
+ if (q.sub !== undefined) override.sub = String(q.sub).slice(0, 60);
+ if (q.image !== undefined) override.image = String(q.image).trim();
+ if (q.watermark !== undefined) override.watermark = q.watermark !== '0';
+
+ try {
+ const { buildWelcomeCard } = await import('../bot/welcome-card.js');
+ const png = await buildWelcomeCard({
+ username: String(q.name ?? '').trim() || client.user?.username || 'Neues Mitglied',
+ avatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }),
+ memberNumber: Number(q.count) || 42,
+ }, override);
+ reply.header('Cache-Control', 'no-store');
+ return reply.type('image/png').send(png);
+ } catch (error) {
+ request.log.error({ err: error }, 'Willkommens-Vorschau fehlgeschlagen');
+ return reply.code(500).send({ error: 'Vorschau fehlgeschlagen' });
+ }
+ });
+
// Funktionsliste für die Produktseite — öffentlich und ohne Zustand:
// was der Bot kann, nicht was hier gerade eingeschaltet ist. Quelle ist
// dasselbe Register wie die Module, damit die Seite nicht veraltet.
@@ -493,6 +525,20 @@ ${rssItems}
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
modmail_channel_id: getSetting('modmail_channel_id') ?? '',
welcome_channel_id: getSetting('welcome_channel_id') ?? '',
+ ...(() => {
+ const card = welcomeCard();
+ return {
+ welcome_card_enabled: card.enabled,
+ welcome_card_bg: card.background,
+ welcome_card_accent: card.accent,
+ welcome_card_accent2: card.accent2,
+ welcome_card_text: card.text,
+ welcome_card_kicker: card.kicker,
+ welcome_card_sub: card.sub,
+ welcome_card_image: card.image,
+ welcome_card_watermark: card.watermark,
+ };
+ })(),
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
status_channel_id: getSetting('status_channel_id') ?? '',
voting_channel_id: getSetting('voting_channel_id') ?? '',
@@ -647,6 +693,34 @@ ${rssItems}
if (body.level_rewards !== undefined) {
setSetting('level_rewards', String(body.level_rewards).trim());
}
+ // Willkommens-Karte
+ if (body.welcome_card_enabled !== undefined) {
+ setSetting('welcome_card_enabled', body.welcome_card_enabled ? '1' : '0');
+ }
+ if (body.welcome_card_watermark !== undefined) {
+ setSetting('welcome_card_watermark', body.welcome_card_watermark ? '1' : '0');
+ }
+ for (const key of ['welcome_card_bg', 'welcome_card_accent', 'welcome_card_accent2', 'welcome_card_text']) {
+ if (body[key] === undefined) continue;
+ const value = String(body[key]).trim();
+ if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
+ return reply.code(400).send({ error: `${key}: Hex-Farbe wie #f5c518 erwartet` });
+ }
+ setSetting(key, value.toLowerCase());
+ }
+ if (body.welcome_card_kicker !== undefined) {
+ setSetting('welcome_card_kicker', String(body.welcome_card_kicker).slice(0, 24));
+ }
+ if (body.welcome_card_sub !== undefined) {
+ setSetting('welcome_card_sub', String(body.welcome_card_sub).slice(0, 60));
+ }
+ if (body.welcome_card_image !== undefined) {
+ const value = String(body.welcome_card_image).trim();
+ if (value && !/^https?:\/\//i.test(value)) {
+ return reply.code(400).send({ error: 'welcome_card_image: Bild-Adresse mit http(s) erwartet' });
+ }
+ setSetting('welcome_card_image', value);
+ }
// Branding
if (body.brand_name !== undefined) {
setSetting('brand_name', String(body.brand_name).trim().slice(0, 40) || 'D4RKST3R');