Config: Speichern-Leiste und durchsuchbare Kanal-Auswahl
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Der Speichern-Knopf stand am Seitenende und war bei den zweispaltigen
Bereichen oft gar nicht zu sehen. Vor allem gab es keinen Hinweis, dass
etwas offen ist — wer einen Kanal umstellte und neu lud, verlor die
Änderung kommentarlos.

Jetzt erscheint unten eine Leiste, sobald sich etwas vom gespeicherten
Stand unterscheidet: Anzahl der Änderungen, Verwerfen, Speichern. Beim
Verlassen mit offenen Änderungen fragt der Browser nach. Der Vergleich
zieht Bot-Name und -Beschreibung mit ein, die außerhalb des Formulars
gepflegt werden.

Die 27 Kanal- und Rollen-Auswahlen sind keine nativen Dropdowns mehr,
sondern dieselbe durchsuchbare Liste wie bei den gamedig-IDs: tippen
filtert, Enter nimmt den ersten Treffer. Bei zwölf Kanälen merkt man das
schon, bei fünfzig ist es der Unterschied.

Beim Testen zwei echte Fehler gefunden, beide älter als diese Änderung:

Nicht gesetzte Kanäle kommen als null aus der API. String(null) ist
"null" — also eine Kanal-ID, die es nie gibt. Auf einer frischen
Installation scheiterte damit das Speichern *jeder* Einstellung, solange
Devlog- oder Commit-Kanal leer waren. Sichtbar wurde das erst, weil die
Leiste jetzt überall auftaucht statt nur auf vier Bereichen.

Außerdem wurde jeder Kanal im Formular geprüft, auch die unveränderten.
Ein inzwischen gelöschter Kanal hätte damit das Speichern aller anderen
Einstellungen dauerhaft blockiert. Geprüft wird jetzt nur, was sich
gegenüber dem ausgelieferten Stand wirklich ändert.

Nachtrag zum Selbsttest: der erste Versuch, die Auswahlen per Regex zu
ersetzen, lief mit DOTALL und griff dadurch über <select>-Grenzen hinweg —
zwei fremde Dropdowns wurden zerlegt. Der Build hat es gemeldet, die Datei
wurde zurückgesetzt und der Ersatz zeilenweise wiederholt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:11:31 +02:00
co-authored by Claude Fable 5
parent 2e58268f8d
commit 1c3fda8d64
3 changed files with 382 additions and 122 deletions
+301 -119
View File
@@ -153,6 +153,82 @@ function GameSelect({ presets, value, onChange }) {
);
}
/**
* Durchsuchbare Auswahl für Kanäle und Rollen — dieselbe Bedienung wie bei den
* gamedig-IDs. Ein natives <select> lässt sich nicht durchsuchen; bei vielen
* Kanälen scrollt man sonst durch eine lange Liste.
*
* @param {{
* items: Array<{ id: string, label: string, hint?: string }>,
* value: string, onChange: (id: string) => void, empty: string,
* }} props — `empty` ist die Beschriftung für „nichts gewählt"
*/
function PickSelect({ items, value, onChange, empty }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const wrapRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
setOpen(false);
setQuery('');
}
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const current = items.find((i) => i.id === value);
const q = query.trim().toLowerCase();
const filtered = q
? items.filter((i) => `${i.label} ${i.hint ?? ''}`.toLowerCase().includes(q))
: items;
return (
<div className="picksel" ref={wrapRef}>
{/* Zugeklappt steht die Auswahl im Feld, aufgeklappt tippt man die Suche */}
<input
type="text"
value={open ? query : (current?.label ?? '')}
placeholder={current ? current.label : empty}
onFocus={() => { setOpen(true); setQuery(''); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onKeyDown={(e) => {
if (e.key === 'Escape') { setOpen(false); setQuery(''); e.target.blur(); }
if (e.key === 'Enter' && filtered[0]) {
onChange(filtered[0].id); setOpen(false); setQuery(''); e.target.blur();
}
}}
/>
<span className="picksel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>
<IconChevronDown size={14} />
</span>
{open && (
<div className="picksel-list">
<button
type="button" className={`picksel-item${value === '' ? ' active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onChange(''); setOpen(false); setQuery(''); }}
>
<span className="picksel-label muted">{empty}</span>
</button>
{filtered.map((i) => (
<button
type="button" key={i.id}
className={`picksel-item${i.id === value ? ' active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onChange(i.id); setOpen(false); setQuery(''); }}
>
<span className="picksel-label">{i.label}</span>
{i.hint && <span className="picksel-hint">{i.hint}</span>}
</button>
))}
{filtered.length === 0 && <span className="picksel-none">Nichts gefunden</span>}
</div>
)}
</div>
);
}
function fmtUptime(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
@@ -646,6 +722,9 @@ export default function Settings({ me }) {
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
// Vergleichsstand mitziehen, sonst meldet die Leiste weiter
// „Änderungen offen", obwohl gerade gespeichert wurde
setData((d) => (d ? { ...d, settings: res.settings } : d));
setGiteaToken('');
setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert & angewendet');
@@ -700,6 +779,43 @@ export default function Settings({ me }) {
setTimeout(() => setFeedback(null), 3000);
}
// Welche Einstellungen weichen vom gespeicherten Stand ab? Der Brand-Tab
// pflegt Bot-Name und -Beschreibung außerhalb von `form` — die zählen mit,
// sonst meldet die Leiste „nichts geändert", während doch etwas offen ist.
const changedKeys = data?.settings && form
? Object.keys(form).filter((k) => String(form[k] ?? '') !== String(data.settings[k] ?? ''))
: [];
const brandChanged = Boolean(data) && (
botName !== (data.status.botName ?? '')
|| botDesc !== (data.status.botDescription ?? '')
|| giteaToken.trim() !== ''
|| twitchCreds.id.trim() !== '' || twitchCreds.secret.trim() !== ''
);
const dirtyCount = changedKeys.length + (brandChanged ? 1 : 0);
/** Alles zurück auf den gespeicherten Stand */
function discardChanges() {
setForm(data.settings);
setBotName(data.status.botName ?? '');
setBotDesc(data.status.botDescription ?? '');
setGiteaToken('');
setTwitchCreds({ id: '', secret: '' });
flash('Änderungen verworfen');
}
// Beim Verlassen mit offenen Änderungen nachfragen — vorher gingen sie
// kommentarlos verloren.
useEffect(() => {
if (dirtyCount === 0) return undefined;
const warn = (e) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirtyCount]);
/** Speichern aus der Leiste: saveBrand schickt das Formular mit und deckt
* damit beide Fälle ab, wenn auch Brand-Felder offen sind. */
const saveAll = () => (brandChanged ? saveBrand() : save());
async function save() {
try {
const body = { ...form };
@@ -707,6 +823,9 @@ export default function Settings({ me }) {
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
// Vergleichsstand mitziehen, sonst meldet die Leiste weiter
// „Änderungen offen", obwohl gerade gespeichert wurde
setData((d) => (d ? { ...d, settings: res.settings } : d));
setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert');
} catch {
@@ -824,11 +943,11 @@ export default function Settings({ me }) {
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
const channelOptions = data.channels.map((c) => (
<option key={c.id} value={c.id}>#{c.name} {c.guild}</option>
));
const roleOptions = (data.roles ?? []).map((r) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</option>
const channelItems = data.channels.map((c) => ({ id: c.id, label: `#${c.name}`, hint: c.guild }));
const roleItems = (data.roles ?? []).map((r) => ({ id: r.id, label: `@${r.name}`, hint: r.guild }));
// Der Composer bringt seine eigene Auswahl mit und erwartet <option>-Elemente
const channelOptions = channelItems.map((c) => (
<option key={c.id} value={c.id}>{c.label} {c.hint}</option>
));
const channelName = (id) => {
const c = data.channels.find((x) => x.id === id);
@@ -992,30 +1111,36 @@ export default function Settings({ me }) {
<div className="field">
<label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label>
<div className="field-row">
<select value={form.devlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.devlog_channel_id ?? ''}
onChange={(v) => setForm({ ...form, devlog_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
</div>
</div>
<div className="field">
<label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label>
<div className="field-row">
<select value={form.commit_channel_id ?? ''} onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.commit_channel_id ?? ''}
onChange={(v) => setForm({ ...form, commit_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('commit')}>Test</button>
</div>
</div>
<div className="field">
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases leer = aus</span></label>
<div className="field-row">
<select value={form.release_channel_id ?? ''} onChange={(e) => setForm({ ...form, release_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.release_channel_id ?? ''}
onChange={(v) => setForm({ ...form, release_channel_id: v })}
empty="— deaktiviert —"
/>
<button className="btn" onClick={() => sendTest('release')}>Test</button>
</div>
</div>
@@ -1025,10 +1150,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Devlog</h2>
<div className="field">
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post leer = kein Ping</span></label>
<select value={form.devlog_ping_role_id ?? ''} onChange={(e) => setForm({ ...form, devlog_ping_role_id: e.target.value })}>
<option value=""> kein Ping </option>
{roleOptions}
</select>
<PickSelect
items={roleItems}
value={form.devlog_ping_role_id ?? ''}
onChange={(v) => setForm({ ...form, devlog_ping_role_id: v })}
empty="— kein Ping —"
/>
</div>
<div className="field">
<label className="toggle">
@@ -1076,17 +1203,21 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Geburtstage</h2>
<div className="field">
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
<select value={form.birthday_channel_id ?? ''} onChange={(e) => setForm({ ...form, birthday_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.birthday_channel_id ?? ''}
onChange={(v) => setForm({ ...form, birthday_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field">
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
<select value={form.birthday_role_id ?? ''} onChange={(e) => setForm({ ...form, birthday_role_id: e.target.value })}>
<option value="">— keine Rolle —</option>
{roleOptions}
</select>
<PickSelect
items={roleItems}
value={form.birthday_role_id ?? ''}
onChange={(v) => setForm({ ...form, birthday_role_id: v })}
empty=" keine Rolle "
/>
</div>
</div>
@@ -1094,10 +1225,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Playtester</h2>
<div className="field">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<select value={form.playtester_role_id ?? ''} onChange={(e) => setForm({ ...form, playtester_role_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{roleOptions}
</select>
<PickSelect
items={roleItems}
value={form.playtester_role_id ?? ''}
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
empty=" deaktiviert "
/>
</div>
{playtesters.length > 0 && (
<div className="key-list">
@@ -1149,26 +1282,32 @@ export default function Settings({ me }) {
<div className="field">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
<div className="field-row">
<select value={form.starboard_channel_id ?? ''} onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.starboard_channel_id ?? ''}
onChange={(v) => setForm({ ...form, starboard_channel_id: v })}
empty=" deaktiviert "
/>
<input type="text" style={{ width: '6rem', flex: 'none' }} title="Schwellwert (Anzahl )" value={form.starboard_threshold} onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })} />
</div>
</div>
<div className="field">
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label>
<select value={form.screenshot_channel_id ?? ''} onChange={(e) => setForm({ ...form, screenshot_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.screenshot_channel_id ?? ''}
onChange={(v) => setForm({ ...form, screenshot_channel_id: v })}
empty=" deaktiviert "
/>
</div>
<div className="field">
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label>
<select value={form.voting_channel_id ?? ''} onChange={(e) => setForm({ ...form, voting_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.voting_channel_id ?? ''}
onChange={(v) => setForm({ ...form, voting_channel_id: v })}
empty=" deaktiviert "
/>
</div>
</div>
@@ -1202,10 +1341,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Rollen bei Join</h2>
<div className="field">
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch leer = aus</span></label>
<select value={form.autorole_id ?? ''} onChange={(e) => setForm({ ...form, autorole_id: e.target.value })}>
<option value=""> deaktiviert </option>
{roleOptions}
</select>
<PickSelect
items={roleItems}
value={form.autorole_id ?? ''}
onChange={(v) => setForm({ ...form, autorole_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<label className="toggle">
@@ -1250,10 +1391,12 @@ export default function Settings({ me }) {
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
<div className="field-row">
<input type="text" placeholder="Titel, z. B. 🎭 Hol dir deine Rollen" value={menuDraft.title} onChange={(e) => setMenuDraft({ ...menuDraft, title: e.target.value })} />
<select value={menuDraft.channel_id} onChange={(e) => setMenuDraft({ ...menuDraft, channel_id: e.target.value })}>
<option value=""> Kanal wählen </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={menuDraft.channel_id}
onChange={(v) => setMenuDraft({ ...menuDraft, channel_id: v })}
empty="— Kanal wählen —"
/>
</div>
</div>
<div className="field">
@@ -1274,10 +1417,12 @@ export default function Settings({ me }) {
<div className="rr-row" key={i}>
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
<select value={entry.role_id} onChange={(e) => setMenuEntry(i, { role_id: e.target.value })}>
<option value="" disabled> Rolle </option>
{roleOptions}
</select>
<PickSelect
items={roleItems}
value={entry.role_id}
onChange={(v) => setMenuEntry(i, { role_id: v })}
empty="— Rolle —"
/>
<select
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
title="Button-Farbe"
@@ -1318,31 +1463,39 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Moderation &amp; Kontakt</h2>
<div className="field">
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal DMs an den Bot landen hier als Threads</span></label>
<select value={form.modmail_channel_id ?? ''} onChange={(e) => setForm({ ...form, modmail_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.modmail_channel_id ?? ''}
onChange={(v) => setForm({ ...form, modmail_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label>
<select value={form.ticket_channel_id ?? ''} onChange={(e) => setForm({ ...form, ticket_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.ticket_channel_id ?? ''}
onChange={(v) => setForm({ ...form, ticket_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
<select value={form.welcome_channel_id ?? ''} onChange={(e) => setForm({ ...form, welcome_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.welcome_channel_id ?? ''}
onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT gelöschte/bearbeitete Nachrichten</span></label>
<select value={form.modlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, modlog_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.modlog_channel_id ?? ''}
onChange={(v) => setForm({ ...form, modlog_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
</div>
@@ -1466,18 +1619,24 @@ export default function Settings({ me }) {
</div>
<div className="field">
<div className="field-row">
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
<option value=""> Post-Kanal (öffentlich) </option>
{channelOptions}
</select>
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
<option value=""> Review-Kanal (Staff) </option>
{channelOptions}
</select>
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
<option value=""> Rolle bei Annahme </option>
{roleOptions}
</select>
<PickSelect
items={channelItems}
value={formDraft.post_channel_id}
onChange={(v) => setFormDraft({ ...formDraft, post_channel_id: v })}
empty="— Post-Kanal (öffentlich) —"
/>
<PickSelect
items={channelItems}
value={formDraft.review_channel_id}
onChange={(v) => setFormDraft({ ...formDraft, review_channel_id: v })}
empty="— Review-Kanal (Staff) —"
/>
<PickSelect
items={roleItems}
value={formDraft.approve_role_id}
onChange={(v) => setFormDraft({ ...formDraft, approve_role_id: v })}
empty="— Rolle bei Annahme —"
/>
</div>
</div>
<div className="field">
@@ -1527,10 +1686,12 @@ export default function Settings({ me }) {
<div className="field">
<label>Neuer geplanter Post</label>
<div className="field-row">
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={schedDraft.channel_id}
onChange={(v) => setSchedDraft({ ...schedDraft, channel_id: v })}
empty="— Kanal wählen —"
/>
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
<option value="once">einmalig</option>
<option value="daily">täglich</option>
@@ -1743,17 +1904,21 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Events, Social &amp; Voice</h2>
<div className="field">
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
<select value={form.events_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, events_announce_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.events_announce_channel_id ?? ''}
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + neue YouTube-Videos</span></label>
<select value={form.social_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, social_announce_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.social_announce_channel_id ?? ''}
onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div className="field">
<div className="field-row">
@@ -1770,10 +1935,12 @@ export default function Settings({ me }) {
</div>
<div className="field">
<label>Temp-Voice-Hub <span className="field-hint">Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
<select value={form.tempvoice_channel_id ?? ''} onChange={(e) => setForm({ ...form, tempvoice_channel_id: e.target.value })}>
<option value="">— deaktiviert —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.tempvoice_channel_id ?? ''}
onChange={(v) => setForm({ ...form, tempvoice_channel_id: v })}
empty=" deaktiviert "
/>
</div>
</div>
@@ -1789,10 +1956,12 @@ export default function Settings({ me }) {
<div className="field">
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
<div className="field-row">
<select value={form.backup_channel_id ?? ''} onChange={(e) => setForm({ ...form, backup_channel_id: e.target.value })}>
<option value="">— nur lokal —</option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.backup_channel_id ?? ''}
onChange={(v) => setForm({ ...form, backup_channel_id: v })}
empty=" nur lokal "
/>
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
</div>
</div>
@@ -1924,17 +2093,21 @@ export default function Settings({ me }) {
<div className="field-row">
<div style={{ flex: 1 }}>
<label>Status-Kanal</label>
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.status_channel_id ?? ''}
onChange={(v) => setForm({ ...form, status_channel_id: v })}
empty="— deaktiviert —"
/>
</div>
<div style={{ flex: 1 }}>
<label>Alert-Kanal <span className="field-hint">🚨 down / online</span></label>
<select value={form.server_alert_channel_id ?? ''} onChange={(e) => setForm({ ...form, server_alert_channel_id: e.target.value })}>
<option value=""> keine Alerts </option>
{channelOptions}
</select>
<PickSelect
items={channelItems}
value={form.server_alert_channel_id ?? ''}
onChange={(v) => setForm({ ...form, server_alert_channel_id: v })}
empty="— keine Alerts —"
/>
</div>
</div>
</div>
@@ -2477,7 +2650,6 @@ export default function Settings({ me }) {
}[tab];
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
return (
<>
@@ -2535,13 +2707,23 @@ export default function Settings({ me }) {
<div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
{content}
{showSave && (
<div className="settings-actions">
<button className="btn btn-save" onClick={save}>Speichern</button>
{/* Platz, damit die Speichern-Leiste nichts verdeckt */}
{dirtyCount > 0 && <div style={{ height: '4.5rem' }} />}
</div>
</div>
{/* Erscheint erst, wenn wirklich etwas offen ist — vorher stand der
Knopf am Seitenende und war bei langen Bereichen nicht zu sehen. */}
{dirtyCount > 0 && (
<div className="save-bar" role="status">
<span className="save-bar-text">
{dirtyCount === 1 ? '1 Änderung offen' : `${dirtyCount} Änderungen offen`}
</span>
<button className="btn btn-ghost" onClick={discardChanges}>Verwerfen</button>
<button className="btn btn-save" onClick={saveAll}>Speichern</button>
</div>
)}
</div>
</div>
{feedback && <div className="feedback-float">{feedback}</div>}
</section>
</>
+61
View File
@@ -1341,6 +1341,41 @@ label.toggle {
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
.gamesel-item:hover .gamesel-id { color: var(--muted); }
/* Durchsuchbare Kanal-/Rollen-Auswahl (ersetzt die nativen Dropdowns) */
.picksel { position: relative; max-width: 44rem; }
.picksel input {
width: 100%; padding-right: 2rem;
background: #0d0d0d; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-family: var(--body); font-size: .95rem;
padding: .55rem .8rem; outline: none;
transition: border-color .2s;
}
.picksel input:focus { border-color: var(--neon); }
.picksel-caret {
position: absolute; right: .6rem; top: 50%; transform: translateY(-50%);
display: inline-flex; color: var(--muted2); cursor: pointer;
}
.picksel-caret:hover { color: var(--neon); }
.picksel-list {
position: absolute; z-index: 60; top: calc(100% + 4px); left: 0; right: 0;
max-height: 17rem; overflow-y: auto;
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, .65);
}
.picksel-item {
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
width: 100%; padding: .45rem .75rem; border: 0; background: none; cursor: pointer;
color: var(--text); font-family: var(--body); font-size: .93rem; text-align: left;
}
.picksel-item:hover, .picksel-item.active { background: var(--bg3); color: var(--neon); }
.picksel-label.muted { color: var(--muted2); font-style: italic; }
.picksel-hint { color: var(--muted2); font-family: var(--mono); font-size: .68rem; white-space: nowrap; }
.picksel-item:hover .picksel-hint { color: var(--muted); }
.picksel-none {
display: block; padding: .6rem .75rem;
color: var(--muted2); font-family: var(--mono); font-size: .7rem;
}
/* ── Member-Bereich: Gate-Banner, Profil, Web-Voting ── */
.gate-banner {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
@@ -1994,6 +2029,32 @@ button.with-icon, a.with-icon { justify-content: center; }
}
.empty-actions { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; margin-top: .8rem; }
/* ── SPEICHERN-LEISTE ──────────────────────────────── */
/* Klebt am unteren Rand, sobald etwas offen ist. Vorher stand der Knopf am
Seitenende — bei den zweispaltigen Bereichen sah man ihn oft gar nicht. */
.save-bar {
position: sticky; bottom: 1.2rem; z-index: 300;
display: flex; align-items: center; gap: 1rem;
width: fit-content; max-width: 100%;
margin: -3.5rem auto 0;
padding: .7rem .9rem .7rem 1.3rem;
background: rgba(18, 18, 18, .96);
border: 1px solid var(--neon);
border-radius: 99px;
box-shadow: 0 10px 30px rgba(0, 0, 0, .55), 0 0 20px rgba(245, 197, 24, .12);
animation: riseIn .25s ease both;
}
.save-bar-text {
font-family: var(--mono); font-size: .68rem;
letter-spacing: .12em; text-transform: uppercase;
color: var(--neon); white-space: nowrap;
}
.save-bar .btn { padding: .45rem 1.1rem; font-size: .62rem; }
@media (max-width: 640px) {
.save-bar { gap: .5rem; padding: .6rem .6rem .6rem .9rem; }
.save-bar-text { font-size: .6rem; letter-spacing: .08em; }
}
/* ── NACH OBEN ─────────────────────────────────────── */
.to-top {
position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 400;
+20 -3
View File
@@ -664,6 +664,11 @@ ${rssItems}
if (requireScope(request, reply, 'settings')) return;
const body = request.body ?? {};
// Stand vor der Änderung — und zwar genau so, wie ihn GET /api/settings
// ausliefert (Einstellung ODER Env-Fallback). Nur daran lässt sich
// ablesen, was das Formular wirklich geändert hat.
const before = currentSettings();
const unchanged = (key, value) => value === String(before[key] ?? '');
// Sensible Felder sind Owner-only — für Team-Mitglieder still ignorieren
if (!isAdmin(getSessionUser(request))) {
@@ -683,10 +688,21 @@ ${rssItems}
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
'birthday_channel_id',
];
// Auch Devlog- und Commit-Kanal dürfen leer sein: leer heißt „noch nicht
// eingerichtet", und der Bot kommt damit klar. Vorher scheiterte das
// Speichern JEDER Einstellung, solange einer der beiden nicht gesetzt war
// — auf einer frischen Installation also immer.
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
const value = String(body[key]);
if (value === '' && OPTIONAL_CHANNELS.includes(key)) {
// `?? ''` ist wichtig: nicht gesetzte Kanäle kommen als null aus der
// API zurück, und String(null) wäre "null" — also ein Kanal, den es
// nie gibt.
const value = String(body[key] ?? '');
// Nur geprüft, was sich tatsächlich ändert. Sonst blockiert ein
// inzwischen gelöschter Kanal das Speichern *aller* Einstellungen,
// weil das Formular ihn unverändert mitschickt.
if (unchanged(key, value)) continue;
if (value === '') {
setSetting(key, '');
continue;
}
@@ -699,7 +715,8 @@ ${rssItems}
// Rollen: müssen existieren ('' = Feature aus)
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
if (body[key] === undefined) continue;
const value = String(body[key]);
const value = String(body[key] ?? ''); // null = keine Rolle, siehe oben
if (unchanged(key, value)) continue; // unverändert → nicht prüfen
if (value !== '') {
const exists = [...client.guilds.cache.values()].some((g) =>
g.roles?.cache?.has?.(value)