Willkommens-Karte einstellbar: Farben, Bild, Texte, Live-Vorschau
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:
@@ -305,6 +305,31 @@ export default function Settings({ me }) {
|
|||||||
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
||||||
}, [data]);
|
}, [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
|
// Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHash = () => {
|
const onHash = () => {
|
||||||
@@ -1244,6 +1269,7 @@ export default function Settings({ me }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabSupport = (
|
const tabSupport = (
|
||||||
|
<>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Moderation & Kontakt</h2>
|
<h2 className="settings-title">// Moderation & Kontakt</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -1275,6 +1301,88 @@ export default function Settings({ me }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Willkommens-Karte</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
|
||||||
|
zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.welcome_card_enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Karte rendern</span>
|
||||||
|
<span className="field-hint">aus = nur das Text-Embed mit Avatar als Vorschaubild</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.welcome_card_enabled && (
|
||||||
|
<>
|
||||||
|
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
|
||||||
|
|
||||||
|
<div className="wc-colors">
|
||||||
|
{[
|
||||||
|
['welcome_card_bg', 'Hintergrund'],
|
||||||
|
['welcome_card_accent', 'Akzent 1'],
|
||||||
|
['welcome_card_accent2', 'Akzent 2'],
|
||||||
|
['welcome_card_text', 'Name'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label className="wc-color" key={key}>
|
||||||
|
<input
|
||||||
|
className="color-pick"
|
||||||
|
type="color"
|
||||||
|
value={form[key] ?? '#000000'}
|
||||||
|
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Kleine Zeile <span className="field-hint">über dem Namen — leer = weglassen</span></label>
|
||||||
|
<input
|
||||||
|
type="text" maxLength={24} placeholder="WILLKOMMEN"
|
||||||
|
value={form.welcome_card_kicker ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_kicker: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Zeile darunter <span className="field-hint">{'{count}'} wird zur Mitglieds-Nummer — leer = weglassen</span></label>
|
||||||
|
<input
|
||||||
|
type="text" maxLength={60} placeholder="Member #{count}"
|
||||||
|
value={form.welcome_card_sub ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_sub: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Hintergrundbild <span className="field-hint">https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster</span></label>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="https://cdn.d4rkst3r.de/welcome.png"
|
||||||
|
value={form.welcome_card_image ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_image: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.welcome_card_watermark}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_watermark: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Markenname im Hintergrund</span>
|
||||||
|
<span className="field-hint">großer, schwach sichtbarer Schriftzug hinten rechts</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabBewerbungen = (
|
const tabBewerbungen = (
|
||||||
|
|||||||
@@ -1109,6 +1109,22 @@ label.toggle {
|
|||||||
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
|
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 */
|
/* Branding-Tab: Farb- und Datei-Inputs */
|
||||||
.color-pick {
|
.color-pick {
|
||||||
width: 3.2rem; height: 2.4rem; flex: none;
|
width: 3.2rem; height: 2.4rem; flex: none;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
||||||
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
|
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||||
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.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 { moduleEnabled } from '../modules.js';
|
||||||
import { renderTemplate } from '../templates.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
|
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
|
||||||
let files = [];
|
let files = [];
|
||||||
try {
|
try {
|
||||||
|
if (!welcomeCard().enabled) throw new Error('Karte abgeschaltet');
|
||||||
const { buildWelcomeCard } = await import('./welcome-card.js');
|
const { buildWelcomeCard } = await import('./welcome-card.js');
|
||||||
const png = await buildWelcomeCard({
|
const png = await buildWelcomeCard({
|
||||||
username: member.displayName,
|
username: member.displayName,
|
||||||
@@ -114,7 +115,10 @@ async function handleMemberAdd(member) {
|
|||||||
embed.setImage('attachment://welcome.png');
|
embed.setImage('attachment://welcome.png');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
|
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 });
|
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
|
||||||
|
|||||||
+67
-28
@@ -1,10 +1,12 @@
|
|||||||
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
|
// Willkommens-Karte: gerendertes PNG (SVG → sharp). Aussehen kommt aus den
|
||||||
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
|
// 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 sharp from 'sharp';
|
||||||
import { brandName } from '../runtime-settings.js';
|
import { brandName, welcomeCard } from '../runtime-settings.js';
|
||||||
|
|
||||||
const W = 900;
|
const W = 900;
|
||||||
const H = 300;
|
const H = 300;
|
||||||
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/** XML-Sonderzeichen im Usernamen entschärfen */
|
/** XML-Sonderzeichen im Usernamen entschärfen */
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
@@ -12,43 +14,76 @@ function esc(s) {
|
|||||||
.replaceAll('"', '"').replaceAll("'", ''');
|
.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.
|
* 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
|
* @returns {Promise<Buffer>} PNG-Buffer
|
||||||
*/
|
*/
|
||||||
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
|
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }, override) {
|
||||||
// Avatar holen und als Data-URI einbetten (128px reicht für den 150px-Kreis)
|
const look = { ...welcomeCard(), ...(override ?? {}) };
|
||||||
let avatarData = '';
|
|
||||||
try {
|
const [avatarData, bgData] = await Promise.all([
|
||||||
const res = await fetch(avatarUrl, { signal: AbortSignal.timeout(5000) });
|
fetchDataUri(avatarUrl),
|
||||||
if (res.ok) {
|
look.image ? fetchDataUri(look.image, 6000) : Promise.resolve(''),
|
||||||
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 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">
|
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>
|
<defs>
|
||||||
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#f5c518" stop-opacity="0"/>
|
<stop offset="0" stop-color="${look.accent}" stop-opacity="0"/>
|
||||||
<stop offset=".3" stop-color="#f5c518"/>
|
<stop offset=".3" stop-color="${look.accent}"/>
|
||||||
<stop offset=".7" stop-color="#ff4d00"/>
|
<stop offset=".7" stop-color="${look.accent2}"/>
|
||||||
<stop offset="1" stop-color="#ff4d00" stop-opacity="0"/>
|
<stop offset="1" stop-color="${look.accent2}" stop-opacity="0"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0" stop-color="#f5c518"/>
|
<stop offset="0" stop-color="${look.accent}"/>
|
||||||
<stop offset="1" stop-color="#ff4d00"/>
|
<stop offset="1" stop-color="${look.accent2}"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<pattern id="grid" width="45" height="45" patternUnits="userSpaceOnUse">
|
<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>
|
</pattern>
|
||||||
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
|
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
|
||||||
|
<clipPath id="card"><rect width="${W}" height="${H}"/></clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect width="${W}" height="${H}" fill="#0a0a0a"/>
|
<rect width="${W}" height="${H}" fill="${look.background}"/>
|
||||||
<rect width="${W}" height="${H}" fill="url(#grid)"/>
|
${bgData
|
||||||
<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
|
? `<image href="${bgData}" x="0" y="0" width="${W}" height="${H}" clip-path="url(#card)" preserveAspectRatio="xMidYMid slice"/>`
|
||||||
font-size="120" fill="#f5c518" fill-opacity=".05">${esc(brandName())}</text>
|
: ''}
|
||||||
|
${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="0" width="${W}" height="3" fill="url(#line)"/>
|
||||||
<rect x="0" y="${H - 3}" 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"/>
|
: `<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="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"
|
${kicker
|
||||||
letter-spacing="10" fill="#8a8378">WILLKOMMEN</text>
|
? `<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"
|
<text x="270" y="182" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="52"
|
||||||
fill="#e8e0d0">${name}</text>
|
fill="${look.text}">${name}</text>
|
||||||
<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
|
${sub
|
||||||
fill="#f5c518">Member #${Number(memberNumber) || '?'}</text>
|
? `<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
|
||||||
|
fill="${look.accent}">${sub}</text>`
|
||||||
|
: ''}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||||
|
|||||||
@@ -229,6 +229,29 @@ export function welcomeChannelId() {
|
|||||||
return getSetting('welcome_channel_id') || null;
|
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 */
|
/** Mod-Log-Kanal (privat!) — leer = Feature aus */
|
||||||
export function modlogChannelId() {
|
export function modlogChannelId() {
|
||||||
return getSetting('modlog_channel_id') || null;
|
return getSetting('modlog_channel_id') || null;
|
||||||
|
|||||||
+75
-1
@@ -29,7 +29,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
|
|||||||
import { computeNextRun } from './scheduled-posts.js';
|
import { computeNextRun } from './scheduled-posts.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.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 { xpForLevel } from '../bot/levels.js';
|
||||||
import { getMilestones } from '../gitea-api.js';
|
import { getMilestones } from '../gitea-api.js';
|
||||||
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
||||||
@@ -185,6 +185,38 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { ok: true, modules: moduleStates() };
|
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:
|
// Funktionsliste für die Produktseite — öffentlich und ohne Zustand:
|
||||||
// was der Bot kann, nicht was hier gerade eingeschaltet ist. Quelle ist
|
// was der Bot kann, nicht was hier gerade eingeschaltet ist. Quelle ist
|
||||||
// dasselbe Register wie die Module, damit die Seite nicht veraltet.
|
// dasselbe Register wie die Module, damit die Seite nicht veraltet.
|
||||||
@@ -493,6 +525,20 @@ ${rssItems}
|
|||||||
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
|
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
|
||||||
modmail_channel_id: getSetting('modmail_channel_id') ?? '',
|
modmail_channel_id: getSetting('modmail_channel_id') ?? '',
|
||||||
welcome_channel_id: getSetting('welcome_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') ?? '',
|
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
|
||||||
status_channel_id: getSetting('status_channel_id') ?? '',
|
status_channel_id: getSetting('status_channel_id') ?? '',
|
||||||
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
||||||
@@ -647,6 +693,34 @@ ${rssItems}
|
|||||||
if (body.level_rewards !== undefined) {
|
if (body.level_rewards !== undefined) {
|
||||||
setSetting('level_rewards', String(body.level_rewards).trim());
|
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
|
// Branding
|
||||||
if (body.brand_name !== undefined) {
|
if (body.brand_name !== undefined) {
|
||||||
setSetting('brand_name', String(body.brand_name).trim().slice(0, 40) || 'D4RKST3R');
|
setSetting('brand_name', String(body.brand_name).trim().slice(0, 40) || 'D4RKST3R');
|
||||||
|
|||||||
Reference in New Issue
Block a user