Werte: 19 feste Zahlen im Panel einstellbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

XP-Beträge, Wartezeiten, Prüf-Intervalle, Obergrenzen und Postzeiten
standen als Konstanten im Code. Wer den Server-Monitor seltener prüfen
lassen wollte oder das Level-System anders austarieren, musste die
Quelldateien anfassen.

src/tuning.js hält sie jetzt an einer Stelle, mit Standard, erlaubtem
Bereich und Erklärung. Der neue Config-Bereich „Werte" zeigt sie nach
Themen gruppiert; neben jedem Feld steht der erlaubte Bereich und der
Standard, angepasste Werte lassen sich einzeln zurücksetzen.

Die Grenzen sind nicht Kosmetik: ein Vertipper beim Prüf-Intervall würde
sonst den Bot in eine Schleife im Sekundentakt schicken. Gespeichert wird
ganz oder gar nicht — ein ungültiger Wert im Formular lässt auch die
gültigen daneben unverändert, statt die Hälfte zu schreiben.

Intervalle laufen nicht mehr über setInterval mit festem Abstand, sondern
über everyTuned: der Wert wird vor jeder Runde neu gelesen. Ein geändertes
Intervall greift damit ab dem nächsten Durchlauf, ohne Neustart. Ein
Fehler in einer Runde beendet die Schleife nicht.

Beim Testen aufgefallen und behoben: Number('') ist 0 und nicht NaN — ein
nie gesetzter Wert wäre dadurch auf sein Minimum gefallen statt auf den
Standard. Das Level-System hätte also 1 XP pro Nachricht vergeben und der
Monitor jede Minute geprüft.

Der Fetch-Wrapper im Frontend hat die Fehlermeldung des Servers verworfen;
jetzt steht im Panel „XP pro Nachricht: 1–500 XP" statt „fehlgeschlagen".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:49:34 +02:00
co-authored by Claude Fable 5
parent fb927d4399
commit 197c2a1ada
17 changed files with 453 additions and 41 deletions
+3
View File
@@ -4,6 +4,9 @@ async function api(path, options = {}) {
if (!res.ok) {
const error = new Error(`API ${res.status}`);
error.status = res.status;
// Der Server erklärt in vielen Fällen, was genau nicht stimmt —
// ohne das hier stünde überall nur „fehlgeschlagen".
error.body = await res.json().catch(() => null);
throw error;
}
return res.json();
+126 -1
View File
@@ -6,7 +6,7 @@ import {
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
IconSearch,
IconSearch, IconSystem as IconTune,
} from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
@@ -36,6 +36,8 @@ const TABS = [
find: 'funktionen an aus schalter aktivieren deaktivieren' },
{ id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick',
find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' },
{ id: 'werte', Icon: IconTune, label: 'Werte', section: 'ueberblick',
find: 'xp cooldown wartezeit intervall prüfung timeout obergrenze zahlen uhrzeit' },
{ id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt',
find: 'avatar banner farbe logo name footer aussehen embed' },
@@ -76,6 +78,7 @@ const TAB_SCOPES = {
status: 'any',
module: 'settings',
texte: 'settings',
werte: 'settings',
brand: null,
feeds: 'settings',
community: 'community',
@@ -227,6 +230,10 @@ export default function Settings({ me }) {
const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage
// Stellwerte (XP, Wartezeiten, Intervalle, Obergrenzen)
const [tuningValues, setTuningValues] = useState([]);
const [tuningGroups, setTuningGroups] = useState([]);
const [tuneDraft, setTuneDraft] = useState({}); // id → eingetippter Wert
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
@@ -298,6 +305,10 @@ export default function Settings({ me }) {
setTemplates(d.templates);
setTemplateGroups(d.groups);
}).catch(() => {});
apiGet('/api/tuning').then((d) => {
setTuningValues(d.values);
setTuningGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -390,6 +401,37 @@ export default function Settings({ me }) {
}
}
/** Alle geänderten Stellwerte auf einmal speichern */
async function saveTuning() {
const changed = Object.fromEntries(
Object.entries(tuneDraft).filter(([id, v]) => {
const current = tuningValues.find((t) => t.id === id);
return current && String(v) !== String(current.value);
})
);
if (Object.keys(changed).length === 0) return;
try {
const res = await apiPut('/api/tuning', changed);
setTuningValues(res.values);
setTuneDraft({});
flash('✓ Werte gespeichert');
} catch (error) {
flash(`${error.body?.error ?? 'Speichern fehlgeschlagen'}`);
}
}
/** Einen Wert auf den Standard zurücksetzen */
async function resetTuning(id) {
try {
const res = await apiPut('/api/tuning', { [id]: '' });
setTuningValues(res.values);
setTuneDraft(({ [id]: _drop, ...rest }) => rest);
flash('✓ Auf den Standard zurückgesetzt');
} catch {
flash('✗ Zurücksetzen fehlgeschlagen');
}
}
/** Platzhalter an der Cursor-Position einfügen */
function insertPlaceholder(tpl, key) {
const field = document.getElementById(`tpl-${tpl.id}`);
@@ -2112,6 +2154,88 @@ export default function Settings({ me }) {
</div>
);
const tuneDirty = tuningValues.some(
(t) => tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value)
);
const tabWerte = (
<div className="settings-section">
<h2 className="settings-title">// Werte</h2>
<p className="section-intro">
Zahlen, die das Verhalten steuern wie viel XP eine Nachricht bringt,
wie oft geprüft wird, wann gepostet wird. Geänderte Intervalle greifen ab
dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte
Bereich; angepasste Werte lassen sich einzeln zurücksetzen.
</p>
{tuningGroups.map((group) => {
const items = tuningValues.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.6rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tune-list">
{items.map((t) => {
const value = tuneDraft[t.id] ?? t.value;
const dirty = tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value);
return (
<div className={`tune-row${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tune-label">
<span className="tune-name">
{t.label}
{t.customized && <span className="tpl-badge">angepasst</span>}
</span>
{t.hint && <span className="tune-hint">{t.hint}</span>}
</div>
<div className="tune-input">
{t.choices ? (
<select
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
>
{t.choices.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
) : (
<input
type="number"
min={t.min} max={t.max} step={1}
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
/>
)}
<span className="tune-unit">{t.unit}</span>
<span className="tune-range">
{t.choices ? t.choices.join(' · ') : `${t.min}${t.max}`}
{' · Standard '}{t.default}
</span>
{t.customized && (
<button
className="btn-mini"
title="Standard wiederherstellen"
onClick={() => resetTuning(t.id)}
>
Standard
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
<div className="settings-actions">
<button className="btn btn-save" disabled={!tuneDirty} onClick={saveTuning}>
Werte speichern
</button>
</div>
</div>
);
const tabSeiten = (
<div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2>
@@ -2300,6 +2424,7 @@ export default function Settings({ me }) {
status: tabStatus,
module: tabModule,
texte: tabTexte,
werte: tabWerte,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
+45
View File
@@ -1884,3 +1884,48 @@ button.with-icon, a.with-icon { justify-content: center; }
/* Schmal ist für die Vorschau kein Platz mehr — Name und Marken reichen */
.tpl-peek { display: none; }
}
/* ── STELLWERTE ────────────────────────────────────── */
.tune-list {
display: flex; flex-direction: column;
border: 1px solid rgba(255, 255, 255, .06);
border-radius: 10px; overflow: hidden;
}
.tune-row {
display: flex; align-items: center; gap: 1.5rem;
justify-content: space-between; flex-wrap: wrap;
padding: .75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, .06);
}
.tune-row:last-child { border-bottom: 0; }
.tune-row.dirty { box-shadow: inset 2px 0 0 var(--neon); }
.tune-label { display: flex; flex-direction: column; gap: .15rem; min-width: 14rem; flex: 1; }
.tune-name {
display: flex; align-items: center; gap: .5rem;
color: var(--text); font-size: .95rem;
}
.tune-hint { color: var(--muted2); font-weight: 300; font-size: .82rem; line-height: 1.4; }
.tune-input { display: flex; align-items: center; gap: .6rem; flex: none; }
.tune-input input, .tune-input select {
width: 6rem; text-align: right;
font-family: var(--mono); font-size: .85rem;
color: var(--text); background: #0d0d0d;
border: 1px solid var(--border); border-radius: 5px;
padding: .4rem .55rem;
}
.tune-input select { text-align: left; }
.tune-input input:focus, .tune-input select:focus { outline: none; border-color: var(--neon); }
.tune-unit {
font-family: var(--mono); font-size: .64rem;
letter-spacing: .12em; text-transform: uppercase;
color: var(--muted); min-width: 6.5rem;
}
.tune-range {
font-family: var(--mono); font-size: .6rem;
letter-spacing: .06em; color: var(--muted2); min-width: 9rem;
}
@media (max-width: 720px) {
.tune-row { gap: .6rem; }
.tune-range { display: none; }
}