Branding-Tab: Name/Farben/Bot-Status/Avatar/Banner + Button-Farben für Rollen-Menüs

- Alle Embeds nutzen jetzt brandName/brandColor/brandColor2/brandFooter aus den
  Settings (Codemod über 20 Module) — Farbe & Name serverweit per Klick änderbar
- Bot-Status (Presence): Typ (Spielt/Schaut/Hört/Status) + Text, sofort angewendet;
  Server-Monitor nutzt die Presence nur noch, wenn kein eigener Status gesetzt ist
- Avatar-/Banner-Upload direkt aufs Bot-Profil (Base64, 8-MB-Limit,
  Discord-Rate-Limit sauber gemeldet)
- Env-Verlagerung: GITEA_API_TOKEN (write-only Setting) und DISCORD_GUILD_ID
  jetzt auch über den Brand-Tab pflegbar — weniger Redeploys
- Rollen-Menüs: Button-Farbe pro Eintrag wählbar (Grau/Blau/Grün/Rot)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:40:08 +02:00
co-authored by Claude Fable 5
parent db9c7d07c9
commit f7b261c648
27 changed files with 395 additions and 93 deletions
+113 -2
View File
@@ -10,6 +10,7 @@ const API_SCOPES = [
const TABS = [
{ id: 'status', icon: '📊', label: 'Status' },
{ id: 'brand', icon: '🎨', label: 'Brand' },
{ id: 'feeds', icon: '📔', label: 'Feeds' },
{ id: 'community', icon: '🙌', label: 'Community' },
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
@@ -43,12 +44,14 @@ export default function Settings({ me }) {
// Rollen-Menüs
const emptyMenu = {
id: null, title: '', description: '', channel_id: '', exclusive: false,
entries: [{ emoji: '', label: '', role_id: '' }],
entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
};
const [menus, setMenus] = useState([]);
const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Composer
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
// Branding
const [giteaToken, setGiteaToken] = useState('');
// Tags + geplante Posts
const [tags, setTags] = useState([]);
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
@@ -78,6 +81,33 @@ export default function Settings({ me }) {
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
}, [me.admin]);
async function uploadImage(target, file) {
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await apiPost(`/api/branding/${target}`, { data: reader.result });
flash(`${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`);
} catch {
flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)');
}
};
reader.readAsDataURL(file);
}
async function saveBrand() {
try {
const body = { ...form };
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
setGiteaToken('');
flash('✓ Gespeichert & angewendet');
} catch {
flash('✗ Speichern fehlgeschlagen (Farben als #rrggbb?)');
}
}
async function saveTagDraft() {
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
flash('✗ Name und Inhalt nötig');
@@ -223,7 +253,9 @@ export default function Settings({ me }) {
setMenuDraft({
id: m.id, title: m.title, description: m.description,
channel_id: m.channel_id ?? '', exclusive: m.exclusive,
entries: m.entries.length ? m.entries : [{ emoji: '', label: '', role_id: '' }],
entries: m.entries.length
? m.entries.map((e) => ({ style: 'secondary', ...e }))
: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
});
const setMenuEntry = (i, patch) =>
@@ -291,6 +323,73 @@ export default function Settings({ me }) {
</div>
);
const tabBrand = (
<>
<div className="settings-section">
<h2 className="settings-title">// Identität</h2>
<div className="field">
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern (NAME // DEVLOG")</span></label>
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
</div>
<div className="field">
<label>Embed-Farben <span className="field-hint">primär (Embeds) · sekundär (Releases, Mod-Aktionen)</span></label>
<div className="field-row">
<input type="color" className="color-pick" value={form.brand_color} onChange={(e) => setForm({ ...form, brand_color: e.target.value })} />
<input type="color" className="color-pick" value={form.brand_color2} onChange={(e) => setForm({ ...form, brand_color2: e.target.value })} />
<span className="field-hint" style={{ alignSelf: 'center' }}>{form.brand_color} · {form.brand_color2}</span>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Bot-Status</h2>
<div className="field">
<label>Presence <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
<div className="field-row">
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
<option value="custom">Status</option>
<option value="playing">Spielt</option>
<option value="watching">Schaut</option>
<option value="listening">Hört</option>
</select>
<input type="text" placeholder="z. B. ⛏️ baut an EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Avatar &amp; Banner</h2>
<p className="section-intro">
Wird direkt beim Bot-Account gesetzt. Discord erlaubt nur ~2 Änderungen pro 10 Minuten.
</p>
<div className="field">
<label>Bot-Avatar <span className="field-hint">PNG/JPG/GIF, quadratisch empfohlen</span></label>
<input type="file" accept="image/*" onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
</div>
<div className="field">
<label>Bot-Banner <span className="field-hint">breites Bild fürs Bot-Profil</span></label>
<input type="file" accept="image/*" onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Aus der Env verlagert</h2>
<div className="field">
<label>Guild-ID <span className="field-hint">für sofortige Slash-Commands; greift beim nächsten Neustart</span></label>
<input type="text" placeholder="Server-ID" value={form.discord_guild_id} onChange={(e) => setForm({ ...form, discord_guild_id: e.target.value })} />
</div>
<div className="field">
<label>Gitea-API-Token <span className="field-hint">{form.gitea_api_token_set ? '✓ gesetzt — neuen Wert eingeben zum Ersetzen' : 'für /bug; wird nie wieder angezeigt'}</span></label>
<input type="password" placeholder="Token (write-only)" value={giteaToken} onChange={(e) => setGiteaToken(e.target.value)} />
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={saveBrand}>Speichern &amp; anwenden</button>
</div>
</>
);
const tabFeeds = (
<>
<div className="settings-section">
@@ -533,6 +632,17 @@ export default function Settings({ me }) {
<option value="" disabled> Rolle </option>
{roleOptions}
</select>
<select
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
title="Button-Farbe"
value={entry.style ?? 'secondary'}
onChange={(e) => setMenuEntry(i, { style: e.target.value })}
>
<option value="secondary"> Grau</option>
<option value="primary">🟦 Blau</option>
<option value="success">🟩 Grün</option>
<option value="danger">🟥 Rot</option>
</select>
<button
className="card-delete"
title="Zeile entfernen"
@@ -822,6 +932,7 @@ export default function Settings({ me }) {
const content = {
status: tabStatus,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
rollen: tabRollen,