Config: Speichern-Leiste und durchsuchbare Kanal-Auswahl
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:
+301
-119
@@ -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 & 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 & 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>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{feedback && <div className="feedback-float">{feedback}</div>}
|
||||
</section>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user