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. +

+ +
+ +
+ + {form.welcome_card_enabled && ( + <> + Vorschau der Willkommens-Karte + +
+ {[ + ['welcome_card_bg', 'Hintergrund'], + ['welcome_card_accent', 'Akzent 1'], + ['welcome_card_accent2', 'Akzent 2'], + ['welcome_card_text', 'Name'], + ].map(([key, label]) => ( + + ))} +
+ +
+ + setForm({ ...form, welcome_card_kicker: e.target.value })} + /> +
+
+ + setForm({ ...form, welcome_card_sub: e.target.value })} + /> +
+
+ + setForm({ ...form, welcome_card_image: e.target.value })} + /> +
+
+ +
+ + )} +
+ ); 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');