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) { function fmtUptime(s) {
const d = Math.floor(s / 86400); const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600); 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(); if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body); const res = await apiPut('/api/settings', body);
setForm(res.settings); 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(''); setGiteaToken('');
setTwitchCreds({ id: '', secret: '' }); setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert & angewendet'); flash('✓ Gespeichert & angewendet');
@@ -700,6 +779,43 @@ export default function Settings({ me }) {
setTimeout(() => setFeedback(null), 3000); 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() { async function save() {
try { try {
const body = { ...form }; const body = { ...form };
@@ -707,6 +823,9 @@ export default function Settings({ me }) {
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim(); if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
const res = await apiPut('/api/settings', body); const res = await apiPut('/api/settings', body);
setForm(res.settings); 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: '' }); setTwitchCreds({ id: '', secret: '' });
flash('✓ Gespeichert'); flash('✓ Gespeichert');
} catch { } 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 (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></>; if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
const channelOptions = data.channels.map((c) => ( const channelItems = data.channels.map((c) => ({ id: c.id, label: `#${c.name}`, hint: c.guild }));
<option key={c.id} value={c.id}>#{c.name} {c.guild}</option> 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 roleOptions = (data.roles ?? []).map((r) => ( const channelOptions = channelItems.map((c) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</option> <option key={c.id} value={c.id}>{c.label} {c.hint}</option>
)); ));
const channelName = (id) => { const channelName = (id) => {
const c = data.channels.find((x) => x.id === id); const c = data.channels.find((x) => x.id === id);
@@ -992,30 +1111,36 @@ export default function Settings({ me }) {
<div className="field"> <div className="field">
<label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label> <label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label>
<div className="field-row"> <div className="field-row">
<select value={form.devlog_channel_id ?? ''} onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}> <PickSelect
<option value="" disabled> Kanal wählen </option> items={channelItems}
{channelOptions} value={form.devlog_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, devlog_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('devlog')}>Test</button> <button className="btn" onClick={() => sendTest('devlog')}>Test</button>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label> <label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label>
<div className="field-row"> <div className="field-row">
<select value={form.commit_channel_id ?? ''} onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}> <PickSelect
<option value="" disabled> Kanal wählen </option> items={channelItems}
{channelOptions} value={form.commit_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, commit_channel_id: v })}
empty="— Kanal wählen —"
/>
<button className="btn" onClick={() => sendTest('commit')}>Test</button> <button className="btn" onClick={() => sendTest('commit')}>Test</button>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases leer = aus</span></label> <label>Release-Kanal <span className="field-hint">Ankündigungen bei neuen Gitea-Releases leer = aus</span></label>
<div className="field-row"> <div className="field-row">
<select value={form.release_channel_id ?? ''} onChange={(e) => setForm({ ...form, release_channel_id: e.target.value })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.release_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, release_channel_id: v })}
empty="— deaktiviert —"
/>
<button className="btn" onClick={() => sendTest('release')}>Test</button> <button className="btn" onClick={() => sendTest('release')}>Test</button>
</div> </div>
</div> </div>
@@ -1025,10 +1150,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Devlog</h2> <h2 className="settings-title">// Devlog</h2>
<div className="field"> <div className="field">
<label>Ping-Rolle <span className="field-hint">wird bei jedem Devlog erwähnt; 🔔-Button am Post leer = kein Ping</span></label> <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 })}> <PickSelect
<option value=""> kein Ping </option> items={roleItems}
{roleOptions} value={form.devlog_ping_role_id ?? ''}
</select> onChange={(v) => setForm({ ...form, devlog_ping_role_id: v })}
empty="— kein Ping —"
/>
</div> </div>
<div className="field"> <div className="field">
<label className="toggle"> <label className="toggle">
@@ -1076,17 +1203,21 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Geburtstage</h2> <h2 className="settings-title">// Geburtstage</h2>
<div className="field"> <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> <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 })}> <PickSelect
<option value="">— deaktiviert —</option> items={channelItems}
{channelOptions} value={form.birthday_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, birthday_channel_id: v })}
empty=" deaktiviert "
/>
</div> </div>
<div className="field"> <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> <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 })}> <PickSelect
<option value="">— keine Rolle —</option> items={roleItems}
{roleOptions} value={form.birthday_role_id ?? ''}
</select> onChange={(v) => setForm({ ...form, birthday_role_id: v })}
empty=" keine Rolle "
/>
</div> </div>
</div> </div>
@@ -1094,10 +1225,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Playtester</h2> <h2 className="settings-title">// Playtester</h2>
<div className="field"> <div className="field">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label> <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 })}> <PickSelect
<option value="">— deaktiviert —</option> items={roleItems}
{roleOptions} value={form.playtester_role_id ?? ''}
</select> onChange={(v) => setForm({ ...form, playtester_role_id: v })}
empty=" deaktiviert "
/>
</div> </div>
{playtesters.length > 0 && ( {playtesters.length > 0 && (
<div className="key-list"> <div className="key-list">
@@ -1149,26 +1282,32 @@ export default function Settings({ me }) {
<div className="field"> <div className="field">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label> <label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug ⭐ landen hier</span></label>
<div className="field-row"> <div className="field-row">
<select value={form.starboard_channel_id ?? ''} onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}> <PickSelect
<option value="">— deaktiviert —</option> items={channelItems}
{channelOptions} value={form.starboard_channel_id ?? ''}
</select> 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 })} /> <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> </div>
<div className="field"> <div className="field">
<label>Screenshot-Kanal <span className="field-hint">Bilder → öffentliche Galerie; Historie: /galerie-backfill</span></label> <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 })}> <PickSelect
<option value="">— deaktiviert —</option> items={channelItems}
{channelOptions} value={form.screenshot_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, screenshot_channel_id: v })}
empty=" deaktiviert "
/>
</div> </div>
<div className="field"> <div className="field">
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf /roadmap</span></label> <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 })}> <PickSelect
<option value="">— deaktiviert —</option> items={channelItems}
{channelOptions} value={form.voting_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, voting_channel_id: v })}
empty=" deaktiviert "
/>
</div> </div>
</div> </div>
@@ -1202,10 +1341,12 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Rollen bei Join</h2> <h2 className="settings-title">// Rollen bei Join</h2>
<div className="field"> <div className="field">
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch leer = aus</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={roleItems}
{roleOptions} value={form.autorole_id ?? ''}
</select> onChange={(v) => setForm({ ...form, autorole_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<label className="toggle"> <label className="toggle">
@@ -1250,10 +1391,12 @@ export default function Settings({ me }) {
<label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label> <label>{menuDraft.id ? `Menü #${menuDraft.id} bearbeiten` : 'Neues Menü'}</label>
<div className="field-row"> <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 })} /> <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 })}> <PickSelect
<option value=""> Kanal wählen </option> items={channelItems}
{channelOptions} value={menuDraft.channel_id}
</select> onChange={(v) => setMenuDraft({ ...menuDraft, channel_id: v })}
empty="— Kanal wählen —"
/>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
@@ -1274,10 +1417,12 @@ export default function Settings({ me }) {
<div className="rr-row" key={i}> <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" 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 })} /> <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 })}> <PickSelect
<option value="" disabled> Rolle </option> items={roleItems}
{roleOptions} value={entry.role_id}
</select> onChange={(v) => setMenuEntry(i, { role_id: v })}
empty="— Rolle —"
/>
<select <select
className={`btn-style-pick style-${entry.style ?? 'secondary'}`} className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
title="Button-Farbe" title="Button-Farbe"
@@ -1318,31 +1463,39 @@ export default function Settings({ me }) {
<h2 className="settings-title">// Moderation &amp; Kontakt</h2> <h2 className="settings-title">// Moderation &amp; Kontakt</h2>
<div className="field"> <div className="field">
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal DMs an den Bot landen hier als Threads</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.modmail_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, modmail_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<label>Ticket-Kanal <span className="field-hint">private Ticket-Threads; Post via /ticket-setup</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.ticket_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, ticket_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.welcome_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT gelöschte/bearbeitete Nachrichten</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.modlog_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, modlog_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
</div> </div>
@@ -1466,18 +1619,24 @@ export default function Settings({ me }) {
</div> </div>
<div className="field"> <div className="field">
<div className="field-row"> <div className="field-row">
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}> <PickSelect
<option value=""> Post-Kanal (öffentlich) </option> items={channelItems}
{channelOptions} value={formDraft.post_channel_id}
</select> onChange={(v) => setFormDraft({ ...formDraft, post_channel_id: v })}
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}> empty="— Post-Kanal (öffentlich) —"
<option value=""> Review-Kanal (Staff) </option> />
{channelOptions} <PickSelect
</select> items={channelItems}
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}> value={formDraft.review_channel_id}
<option value=""> Rolle bei Annahme </option> onChange={(v) => setFormDraft({ ...formDraft, review_channel_id: v })}
{roleOptions} empty="— Review-Kanal (Staff) —"
</select> />
<PickSelect
items={roleItems}
value={formDraft.approve_role_id}
onChange={(v) => setFormDraft({ ...formDraft, approve_role_id: v })}
empty="— Rolle bei Annahme —"
/>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
@@ -1527,10 +1686,12 @@ export default function Settings({ me }) {
<div className="field"> <div className="field">
<label>Neuer geplanter Post</label> <label>Neuer geplanter Post</label>
<div className="field-row"> <div className="field-row">
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}> <PickSelect
<option value="" disabled> Kanal wählen </option> items={channelItems}
{channelOptions} value={schedDraft.channel_id}
</select> 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 })}> <select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
<option value="once">einmalig</option> <option value="once">einmalig</option>
<option value="daily">täglich</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> <h2 className="settings-title">// Events, Social &amp; Voice</h2>
<div className="field"> <div className="field">
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.events_announce_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + neue YouTube-Videos</span></label> <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 })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.social_announce_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div className="field"> <div className="field">
<div className="field-row"> <div className="field-row">
@@ -1770,10 +1935,12 @@ export default function Settings({ me }) {
</div> </div>
<div className="field"> <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> <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 })}> <PickSelect
<option value="">— deaktiviert —</option> items={channelItems}
{channelOptions} value={form.tempvoice_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, tempvoice_channel_id: v })}
empty=" deaktiviert "
/>
</div> </div>
</div> </div>
@@ -1789,10 +1956,12 @@ export default function Settings({ me }) {
<div className="field"> <div className="field">
<label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label> <label>Backup-Upload-Kanal <span className="field-hint">Offsite-Kopie in PRIVATEN Kanal — leer = nur lokal</span></label>
<div className="field-row"> <div className="field-row">
<select value={form.backup_channel_id ?? ''} onChange={(e) => setForm({ ...form, backup_channel_id: e.target.value })}> <PickSelect
<option value="">— nur lokal —</option> items={channelItems}
{channelOptions} value={form.backup_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, backup_channel_id: v })}
empty=" nur lokal "
/>
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button> <button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
</div> </div>
</div> </div>
@@ -1924,17 +2093,21 @@ export default function Settings({ me }) {
<div className="field-row"> <div className="field-row">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label>Status-Kanal</label> <label>Status-Kanal</label>
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}> <PickSelect
<option value=""> deaktiviert </option> items={channelItems}
{channelOptions} value={form.status_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, status_channel_id: v })}
empty="— deaktiviert —"
/>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label>Alert-Kanal <span className="field-hint">🚨 down / online</span></label> <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 })}> <PickSelect
<option value=""> keine Alerts </option> items={channelItems}
{channelOptions} value={form.server_alert_channel_id ?? ''}
</select> onChange={(v) => setForm({ ...form, server_alert_channel_id: v })}
empty="— keine Alerts —"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -2477,7 +2650,6 @@ export default function Settings({ me }) {
}[tab]; }[tab];
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen // Tabs, deren Inhalte über den zentralen Speichern-Button laufen
const showSave = ['feeds', 'community', 'support', 'system'].includes(tab);
return ( return (
<> <>
@@ -2535,13 +2707,23 @@ export default function Settings({ me }) {
<div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}> <div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
{content} {content}
{showSave && ( {/* Platz, damit die Speichern-Leiste nichts verdeckt */}
<div className="settings-actions"> {dirtyCount > 0 && <div style={{ height: '4.5rem' }} />}
<button className="btn btn-save" onClick={save}>Speichern</button>
</div>
)}
</div> </div>
</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>
)}
{feedback && <div className="feedback-float">{feedback}</div>} {feedback && <div className="feedback-float">{feedback}</div>}
</section> </section>
</> </>
+61
View File
@@ -1341,6 +1341,41 @@ label.toggle {
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; } .gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
.gamesel-item:hover .gamesel-id { color: var(--muted); } .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 ── */ /* ── Member-Bereich: Gate-Banner, Profil, Web-Voting ── */
.gate-banner { .gate-banner {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; 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; } .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 ─────────────────────────────────────── */ /* ── NACH OBEN ─────────────────────────────────────── */
.to-top { .to-top {
position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 400; 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; if (requireScope(request, reply, 'settings')) return;
const body = request.body ?? {}; 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 // Sensible Felder sind Owner-only — für Team-Mitglieder still ignorieren
if (!isAdmin(getSessionUser(request))) { if (!isAdmin(getSessionUser(request))) {
@@ -683,10 +688,21 @@ ${rssItems}
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id', 'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
'birthday_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]) { for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue; if (body[key] === undefined) continue;
const value = String(body[key]); // `?? ''` ist wichtig: nicht gesetzte Kanäle kommen als null aus der
if (value === '' && OPTIONAL_CHANNELS.includes(key)) { // 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, ''); setSetting(key, '');
continue; continue;
} }
@@ -699,7 +715,8 @@ ${rssItems}
// Rollen: müssen existieren ('' = Feature aus) // Rollen: müssen existieren ('' = Feature aus)
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) { for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
if (body[key] === undefined) continue; 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 !== '') { if (value !== '') {
const exists = [...client.guilds.cache.values()].some((g) => const exists = [...client.guilds.cache.values()].some((g) =>
g.roles?.cache?.has?.(value) g.roles?.cache?.has?.(value)