diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 8184cda..dd45c37 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 { 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)