From 1c3fda8d648798d92417f024e0f4994d601ac2f4 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 31 Jul 2026 18:11:31 +0200 Subject: [PATCH] Config: Speichern-Leiste und durchsuchbare Kanal-Auswahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 ( +
+ {/* Zugeklappt steht die Auswahl im Feld, aufgeklappt tippt man die Suche */} + { 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(); + } + }} + /> + { e.preventDefault(); setOpen((o) => !o); }}> + + + {open && ( +
+ + {filtered.map((i) => ( + + ))} + {filtered.length === 0 && Nichts gefunden} +
+ )} +
+ ); +} + 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}

Fehler beim Laden ({error}).

; if (!data || !form) return <>{header}

Lade …

; - const channelOptions = data.channels.map((c) => ( - - )); - const roleOptions = (data.roles ?? []).map((r) => ( - + 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 )); const channelName = (id) => { const c = data.channels.find((x) => x.id === id); @@ -992,30 +1111,36 @@ export default function Settings({ me }) {
- + setForm({ ...form, devlog_channel_id: v })} + empty="— Kanal wählen —" + />
- + setForm({ ...form, commit_channel_id: v })} + empty="— Kanal wählen —" + />
- + setForm({ ...form, release_channel_id: v })} + empty="— deaktiviert —" + />
@@ -1025,10 +1150,12 @@ export default function Settings({ me }) {

// Devlog

- + setForm({ ...form, devlog_ping_role_id: v })} + empty="— kein Ping —" + />
@@ -1094,10 +1225,12 @@ export default function Settings({ me }) {

// Playtester

- + setForm({ ...form, playtester_role_id: v })} + empty="— deaktiviert —" + />
{playtesters.length > 0 && (
@@ -1149,26 +1282,32 @@ export default function Settings({ me }) {
- + setForm({ ...form, starboard_channel_id: v })} + empty="— deaktiviert —" + /> setForm({ ...form, starboard_threshold: e.target.value })} />
- + setForm({ ...form, screenshot_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, voting_channel_id: v })} + empty="— deaktiviert —" + />
@@ -1202,10 +1341,12 @@ export default function Settings({ me }) {

// Rollen bei Join

- + setForm({ ...form, autorole_id: v })} + empty="— deaktiviert —" + />
@@ -1274,10 +1417,12 @@ export default function Settings({ me }) {
setMenuEntry(i, { emoji: e.target.value })} /> setMenuEntry(i, { label: e.target.value })} /> - + setMenuEntry(i, { role_id: v })} + empty="— Rolle —" + /> setForm({ ...form, modmail_channel_id: e.target.value })}> - - {channelOptions} - + setForm({ ...form, modmail_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, ticket_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, welcome_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, modlog_channel_id: v })} + empty="— deaktiviert —" + />
@@ -1466,18 +1619,24 @@ export default function Settings({ me }) {
- - - + setFormDraft({ ...formDraft, post_channel_id: v })} + empty="— Post-Kanal (öffentlich) —" + /> + setFormDraft({ ...formDraft, review_channel_id: v })} + empty="— Review-Kanal (Staff) —" + /> + setFormDraft({ ...formDraft, approve_role_id: v })} + empty="— Rolle bei Annahme —" + />
@@ -1527,10 +1686,12 @@ export default function Settings({ me }) {
- + setSchedDraft({ ...schedDraft, channel_id: v })} + empty="— Kanal wählen —" + /> setForm({ ...form, events_announce_channel_id: e.target.value })}> - - {channelOptions} - + setForm({ ...form, events_announce_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, social_announce_channel_id: v })} + empty="— deaktiviert —" + />
@@ -1770,10 +1935,12 @@ export default function Settings({ me }) {
- + setForm({ ...form, tempvoice_channel_id: v })} + empty="— deaktiviert —" + />
@@ -1789,10 +1956,12 @@ export default function Settings({ me }) {
- + setForm({ ...form, backup_channel_id: v })} + empty="— nur lokal —" + />
@@ -1924,17 +2093,21 @@ export default function Settings({ me }) {
- + setForm({ ...form, status_channel_id: v })} + empty="— deaktiviert —" + />
- + setForm({ ...form, server_alert_channel_id: v })} + empty="— keine Alerts —" + />
@@ -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 }) {
{content} - {showSave && ( -
- -
- )} + {/* Platz, damit die Speichern-Leiste nichts verdeckt */} + {dirtyCount > 0 &&
}
+ + {/* Erscheint erst, wenn wirklich etwas offen ist — vorher stand der + Knopf am Seitenende und war bei langen Bereichen nicht zu sehen. */} + {dirtyCount > 0 && ( +
+ + {dirtyCount === 1 ? '1 Änderung offen' : `${dirtyCount} Änderungen offen`} + + + +
+ )} + {feedback &&
{feedback}
} diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d946cb..4a29f0c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; diff --git a/src/web/api.js b/src/web/api.js index 7b5cc46..0772b28 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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)