Modul-Seiten: alles zu einer Funktion an einem Ort
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher war eine Funktion über vier Bereiche verteilt: Schalter unter Module,
Kanal unter Support, Texte unter Texte, Werte unter Werte. Wer die
Begrüßung ändern wollte, brauchte drei Bereiche.

Jetzt öffnet ein Klick auf die Karte die Seite dieser Funktion — mit
Schalter, ihren Kanälen und Feldern, ihren Texten, ihren Werten und dem Weg
zum passenden Werkzeug. Für „Willkommen" also Kanal, Karte, Farben,
Vorschau und beide Texte untereinander.

Bewusst keine 35 Einträge in der Seitenleiste: das Modul-Raster ist die
Navigation, wie bei MEE6. Die Seitenleiste bleibt so lang wie vorher.

Die Seite wird aus den Registern gebaut, nicht von Hand: modules.js kennt
die Felder (neu: `fields` für alles jenseits der Pflichtkanäle, `tool` für
den Verweis auf einen Editor), templates.js hatte die Zuordnung schon,
tuning.js hat sie bekommen. Eine neue Funktion bekommt ihre Seite dadurch
geschenkt — es gibt keinen Ort, an dem man sie vergessen könnte.

Was ein eigenes Werkzeug hat — Rollen-Menüs, Bewerbungen, Composer,
Server-Monitor —, bleibt in seinem Bereich; die Modul-Seite verlinkt nur
dorthin. Dort ist mehr Platz, und die Editoren lassen sich nicht sinnvoll
aus Registern erzeugen.

Texte und Werte bleiben zusätzlich als flache Übersicht: wer alle Texte am
Stück durchgehen will, soll dafür nicht 27 Seiten öffnen müssen.

Beim Umbau ist mir ein Skript in die Quelldatei gelaufen — der Schreibvorgang
scheiterte an einem Emoji und hinterließ modules.js leer. Wiederhergestellt
aus dem letzten Commit; die Skripte schreiben jetzt erst in eine
Zwischendatei und benennen danach um.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:54:31 +02:00
co-authored by Claude Fable 5
parent bea35234c6
commit c88926aba0
7 changed files with 575 additions and 41 deletions
+11
View File
@@ -85,6 +85,17 @@ Share Tech Mono, selbst gehostet).
### Module ### Module
Jede Funktion hat **ihre eigene Seite**: ein Klick auf ihre Karte zeigt Schalter,
Kanäle und Rollen, ihre Texte, ihre Werte und den Weg zum passenden Werkzeug —
statt alles über vier Bereiche verteilt. Für „Willkommen" also Kanal, Karte,
Farben, Vorschau und Begrüßungstext an einem Ort. Die Adresse merkt sich die
Seite (`/settings#module/welcome`).
Gebaut wird sie aus den Registern: [`src/modules.js`](src/modules.js) kennt die
Felder, [`src/templates.js`](src/templates.js) die Texte, [`src/tuning.js`](src/tuning.js)
die Werte — jeweils über `module` verknüpft. Eine neue Funktion bekommt ihre
Seite dadurch geschenkt.
Alle **35 Funktionen** lassen sich im Tab *Module* einzeln ein- und ausschalten — Alle **35 Funktionen** lassen sich im Tab *Module* einzeln ein- und ausschalten —
gruppiert nach Inhalte, Community, Moderation und Server. Jede Karte zeigt, ob das gruppiert nach Inhalte, Community, Moderation und Server. Jede Karte zeigt, ob das
Modul einsatzbereit ist oder noch etwas fehlt (z. B. ein Kanal), und verlinkt direkt Modul einsatzbereit ist oder noch etwas fehlt (z. B. ein Kanal), und verlinkt direkt
+342
View File
@@ -0,0 +1,342 @@
// Die Seite einer einzelnen Funktion: Schalter, ihre Kanäle und Felder, ihre
// Texte, ihre Werte — alles an einem Ort statt über vier Bereiche verteilt.
//
// Vorbild ist die Plugin-Seite von MEE6. Die Felder werden aus den Registern
// gerendert (modules.js, templates.js, tuning.js), nicht von Hand gebaut —
// eine neue Funktion bekommt ihre Seite dadurch geschenkt.
import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost } from '../api.js';
import { moduleIcon } from '../module-icons.jsx';
import { IconArrowLeft, IconWarning, IconCheck, IconRefresh, IconChevronDown } from '../icons.jsx';
/** Ein Feld aus dem Register — Art entscheidet über die Darstellung */
function Feld({ feld, value, onChange, PickSelect, channelItems, roleItems }) {
const gemeinsam = { id: `feld-${feld.setting}` };
if (feld.art === 'kanal' || feld.art === 'rolle') {
return (
<PickSelect
items={feld.art === 'kanal' ? channelItems : roleItems}
value={value ?? ''}
onChange={onChange}
empty={feld.art === 'kanal' ? '— kein Kanal —' : '— keine Rolle —'}
/>
);
}
if (feld.art === 'schalter') {
return (
<label className="toggle">
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
<span>{feld.label}</span>
{feld.hint && <span className="field-hint">{feld.hint}</span>}
</label>
);
}
if (feld.art === 'farbe') {
return (
<label className="wc-color">
<input
className="color-pick" type="color" {...gemeinsam}
value={value ?? '#000000'}
onChange={(e) => onChange(e.target.value)}
/>
<span>{feld.label}</span>
</label>
);
}
return (
<input
type={feld.art === 'zahl' ? 'number' : 'text'} {...gemeinsam}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/**
* @param {{
* id: string, onBack: () => void, onToggle: (id, on) => void,
* form: object, setForm: (f: object) => void,
* PickSelect: Function, channelItems: Array, roleItems: Array,
* welcomePreviewUrl: string, flash: (msg: string) => void,
* goTab: (tab: string) => void,
* }} props
*/
export default function ModulePage(props) {
const { id, onBack, onToggle, form, setForm, PickSelect, channelItems, roleItems,
welcomePreviewUrl, flash, goTab } = props;
const [detail, setDetail] = useState(null);
const [fehler, setFehler] = useState(false);
const [offenerText, setOffenerText] = useState(null);
const [textEntwurf, setTextEntwurf] = useState({});
const [werteEntwurf, setWerteEntwurf] = useState({});
const laden = () => {
setFehler(false);
apiGet(`/api/modules/${id}`).then(setDetail).catch(() => setFehler(true));
};
useEffect(laden, [id]);
if (fehler) return <p className="notice">Diese Funktion konnte nicht geladen werden.</p>;
if (!detail) return <p className="notice">Lade </p>;
const Icon = moduleIcon(detail.id);
async function textSpeichern(textId, wert) {
try {
const res = await apiPut(`/api/texts/${encodeURIComponent(textId)}`, { text: wert });
setDetail((d) => ({ ...d, texte: res.templates.filter((t) => t.module === d.id) }));
setTextEntwurf(({ [textId]: _weg, ...rest }) => rest);
flash(wert.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
async function werteSpeichern() {
const geaendert = Object.fromEntries(
Object.entries(werteEntwurf).filter(([wid, v]) => {
const jetzt = detail.werte.find((w) => w.id === wid);
return jetzt && String(v) !== String(jetzt.value);
})
);
if (Object.keys(geaendert).length === 0) return;
try {
const res = await apiPut('/api/tuning', geaendert);
setDetail((d) => ({ ...d, werte: res.values.filter((w) => w.module === d.id) }));
setWerteEntwurf({});
flash('✓ Werte gespeichert');
} catch (error) {
flash(`${error.body?.error ?? 'Speichern fehlgeschlagen'}`);
}
}
async function anlegen(setting) {
try {
const res = await apiPost('/api/setup/create', { setting });
setForm(res.settings);
laden();
flash(res.wiederverwendet ? `✓ „${res.name}" verknüpft` : `✓ „${res.name}" angelegt`);
} catch (error) {
flash(`${error.body?.error ?? 'Anlegen fehlgeschlagen'}`);
}
}
const werteOffen = detail.werte.some(
(w) => werteEntwurf[w.id] !== undefined && String(werteEntwurf[w.id]) !== String(w.value)
);
return (
<div className="settings-section modpage">
<button className="modpage-back with-icon" onClick={onBack}>
<IconArrowLeft size={14} /> Alle Module
</button>
<div className="modpage-head">
<span className="modpage-icon"><Icon size={26} /></span>
<div className="modpage-titles">
<h2 className="modpage-name">{detail.name}</h2>
<p className="modpage-desc">{detail.desc}</p>
</div>
<button
className={`mod-switch${detail.enabled ? ' on' : ''}`}
title={detail.enabled ? 'Ausschalten' : 'Einschalten'}
onClick={async () => { await onToggle(detail.id, !detail.enabled); laden(); }}
>
<span className="mod-knob" />
</button>
</div>
{detail.enabled && detail.missing.length > 0 && (
<p className="mod-missing with-icon" style={{ marginBottom: '1.2rem' }}>
<IconWarning size={13} /> Fehlt noch: {detail.missing.map((m) => m.label).join(', ')}
</p>
)}
{detail.enabled && detail.missing.length === 0 && (
<p className="mod-ready with-icon" style={{ marginBottom: '1.2rem' }}>
<IconCheck size={13} /> Einsatzbereit
</p>
)}
{/* Kanäle, Rollen und alles Weitere */}
{detail.felder.length > 0 && (
<div className="modpage-block">
<h3 className="settings-title">Einstellungen</h3>
{detail.felder.filter((f) => f.art !== 'farbe').map((f) => (
<div className="field" key={f.setting}>
{f.art !== 'schalter' && (
<label htmlFor={`feld-${f.setting}`}>
{f.label}
{f.hint && <span className="field-hint">{f.hint}</span>}
</label>
)}
<div className="field-row">
<Feld
feld={f}
value={form[f.setting]}
onChange={(v) => setForm({ ...form, [f.setting]: v })}
PickSelect={PickSelect}
channelItems={channelItems}
roleItems={roleItems}
/>
{/* Fehlt eine Voraussetzung, kann der Bot sie anlegen */}
{f.pflicht && detail.missing.some((m) => m.setting === f.setting)
&& detail.missing.find((m) => m.setting === f.setting)?.create && (
<button className="btn-mini btn-create" onClick={() => anlegen(f.setting)}>
{f.art === 'rolle' ? 'Rolle anlegen' : 'Kanal anlegen'}
</button>
)}
</div>
</div>
))}
{detail.felder.some((f) => f.art === 'farbe') && (
<div className="wc-colors">
{detail.felder.filter((f) => f.art === 'farbe').map((f) => (
<Feld
key={f.setting}
feld={f}
value={form[f.setting]}
onChange={(v) => setForm({ ...form, [f.setting]: v })}
PickSelect={PickSelect}
channelItems={channelItems}
roleItems={roleItems}
/>
))}
</div>
)}
</div>
)}
{/* Vorschau, wo es eine gibt */}
{detail.vorschau === 'welcome' && form.welcome_card_enabled && (
<div className="modpage-block">
<h3 className="settings-title">Vorschau</h3>
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
</div>
)}
{/* Texte dieser Funktion */}
{detail.texte.length > 0 && (
<div className="modpage-block">
<h3 className="settings-title">Texte</h3>
<div className="tpl-list">
{detail.texte.map((t) => {
const entwurf = textEntwurf[t.id];
const wert = entwurf ?? t.text;
const geaendert = entwurf != null && entwurf !== t.text;
const offen = offenerText === t.id;
return (
<div className={`tpl-row${offen ? ' open' : ''}${geaendert ? ' dirty' : ''}`} key={t.id}>
<button className="tpl-head" onClick={() => setOffenerText(offen ? null : t.id)}>
<span className="tpl-head-main">
<span className="tpl-name">{t.name}</span>
<span className="tpl-peek">{wert.split('\n')[0]}</span>
</span>
{geaendert && <span className="tpl-badge">ungespeichert</span>}
{!geaendert && t.customized && <span className="tpl-badge">angepasst</span>}
<IconChevronDown className={`tpl-caret${offen ? ' up' : ''}`} size={15} />
</button>
{offen && (
<div className="tpl-body">
<textarea
id={`tpl-${t.id}`} className="tpl-input"
rows={t.long ? 5 : 2} value={wert} spellCheck={false}
onChange={(e) => setTextEntwurf((o) => ({ ...o, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && (
<div className="tpl-vars">
<span className="tpl-vars-label">Bausteine:</span>
{t.vars.map((v) => (
<button
key={v.key} className="tpl-var" title={v.desc}
onClick={() => {
const el = document.getElementById(`tpl-${t.id}`);
const at = el?.selectionStart ?? wert.length;
const neu = `${wert.slice(0, at)}{${v.key}}${wert.slice(el?.selectionEnd ?? at)}`;
setTextEntwurf((o) => ({ ...o, [t.id]: neu }));
}}
>
{`{${v.key}}`}
</button>
))}
</div>
)}
<div className="tpl-actions">
<button className="btn" disabled={!geaendert} onClick={() => textSpeichern(t.id, wert)}>
Speichern
</button>
{(t.customized || geaendert) && (
<button className="btn btn-ghost with-icon" onClick={() => textSpeichern(t.id, '')}>
<IconRefresh size={13} /> Standard
</button>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Werte dieser Funktion */}
{detail.werte.length > 0 && (
<div className="modpage-block">
<h3 className="settings-title">Werte</h3>
<div className="tune-list">
{detail.werte.map((w) => {
const wert = werteEntwurf[w.id] ?? w.value;
const geaendert = werteEntwurf[w.id] !== undefined && String(werteEntwurf[w.id]) !== String(w.value);
return (
<div className={`tune-row${geaendert ? ' dirty' : ''}`} key={w.id}>
<div className="tune-label">
<span className="tune-name">
{w.label}
{w.customized && <span className="tpl-badge">angepasst</span>}
</span>
{w.hint && <span className="tune-hint">{w.hint}</span>}
</div>
<div className="tune-input">
{w.choices ? (
<select value={wert} onChange={(e) => setWerteEntwurf((o) => ({ ...o, [w.id]: e.target.value }))}>
{w.choices.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
) : (
<input
type="number" min={w.min} max={w.max} step={1} value={wert}
onChange={(e) => setWerteEntwurf((o) => ({ ...o, [w.id]: e.target.value }))}
/>
)}
<span className="tune-unit">{w.unit}</span>
<span className="tune-range">{w.min}{w.max} · Standard {w.default}</span>
</div>
</div>
);
})}
</div>
<div className="settings-actions" style={{ marginTop: '.8rem' }}>
<button className="btn btn-save" disabled={!werteOffen} onClick={werteSpeichern}>
Werte speichern
</button>
</div>
</div>
)}
{/* Werkzeuge liegen weiter in ihrem Bereich — hier nur der Weg dorthin */}
{detail.tool && (
<div className="modpage-block">
<h3 className="settings-title">Werkzeug</h3>
<p className="section-intro">
Das Eigentliche wird in einem eigenen Bereich gepflegt dort ist mehr Platz dafür.
</p>
<button className="btn" onClick={() => goTab(detail.tool.tab)}>
{detail.tool.label}
</button>
</div>
)}
</div>
);
}
+45 -20
View File
@@ -9,6 +9,7 @@ import {
IconSearch, IconSystem as IconTune, IconSearch, IconSystem as IconTune,
} from '../icons.jsx'; } from '../icons.jsx';
import { moduleIcon } from '../module-icons.jsx'; import { moduleIcon } from '../module-icons.jsx';
import ModulePage from '../components/ModulePage.jsx';
import { findSettings, SETTING_INDEX } from '../setting-index.js'; import { findSettings, SETTING_INDEX } from '../setting-index.js';
import { Markdown } from '../markdown.jsx'; import { Markdown } from '../markdown.jsx';
@@ -245,10 +246,11 @@ function fmtBytes(b) {
export default function Settings({ me }) { export default function Settings({ me }) {
// Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten // Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten
const [tab, setTabState] = useState( const [tab, setTabState] = useState(
() => TABS.find((t) => t.id === window.location.hash.slice(1))?.id ?? 'status' () => TABS.find((t) => t.id === window.location.hash.slice(1).split('/')[0])?.id ?? 'status'
); );
const setTab = (id) => { const setTab = (id) => {
setTabState(id); setTabState(id);
setOpenModuleState(null);
window.history.replaceState(null, '', `#${id}`); window.history.replaceState(null, '', `#${id}`);
}; };
const [tabSearch, setTabSearch] = useState(''); const [tabSearch, setTabSearch] = useState('');
@@ -304,6 +306,14 @@ export default function Settings({ me }) {
const [modules, setModules] = useState([]); const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]); const [moduleGroups, setModuleGroups] = useState([]);
const [modFilter, setModFilter] = useState('alle'); const [modFilter, setModFilter] = useState('alle');
// Offene Modul-Seite steht mit in der Adresse: #module/welcome
const [openModule, setOpenModuleState] = useState(
() => window.location.hash.split('/')[1] ?? null
);
const setOpenModule = (id) => {
setOpenModuleState(id);
window.history.replaceState(null, '', id ? `#module/${id}` : '#module');
};
const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird
// Grundgerüst-Assistent // Grundgerüst-Assistent
const [plan, setPlan] = useState(null); // Vorschau, null = zu const [plan, setPlan] = useState(null); // Vorschau, null = zu
@@ -459,7 +469,7 @@ export default function Settings({ me }) {
// Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln // Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln
useEffect(() => { useEffect(() => {
const onHash = () => { const onHash = () => {
const id = TABS.find((t) => t.id === window.location.hash.slice(1))?.id; const id = TABS.find((t) => t.id === window.location.hash.slice(1).split('/')[0])?.id;
if (id) setTabState(id); if (id) setTabState(id);
}; };
window.addEventListener('hashchange', onHash); window.addEventListener('hashchange', onHash);
@@ -2355,7 +2365,21 @@ export default function Settings({ me }) {
]; ];
const groupLabel = (id) => moduleGroups.find((g) => g.id === id)?.label ?? ''; const groupLabel = (id) => moduleGroups.find((g) => g.id === id)?.label ?? '';
const tabModule = ( const tabModule = openModule ? (
<ModulePage
id={openModule}
onBack={() => setOpenModule(null)}
onToggle={toggleModule}
form={form}
setForm={setForm}
PickSelect={PickSelect}
channelItems={channelItems}
roleItems={roleItems}
welcomePreviewUrl={welcomePreviewUrl}
flash={flash}
goTab={setTab}
/>
) : (
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Module</h2> <h2 className="settings-title">// Module</h2>
<p className="section-intro"> <p className="section-intro">
@@ -2387,11 +2411,17 @@ export default function Settings({ me }) {
return ( return (
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}> <div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
<div className="mod-head"> <div className="mod-head">
<span className="mod-icon"><Icon size={20} /></span> <button
<span className="mod-titles"> className="mod-open"
<span className="mod-name">{m.name}</span> title={`${m.name} einrichten`}
<span className="mod-group">{groupLabel(m.group)}</span> onClick={() => setOpenModule(m.id)}
</span> >
<span className="mod-icon"><Icon size={20} /></span>
<span className="mod-titles">
<span className="mod-name">{m.name}</span>
<span className="mod-group">{groupLabel(m.group)}</span>
</span>
</button>
<button <button
className={`mod-switch${m.enabled ? ' on' : ''}`} className={`mod-switch${m.enabled ? ' on' : ''}`}
title={m.enabled ? 'Ausschalten' : 'Einschalten'} title={m.enabled ? 'Ausschalten' : 'Einschalten'}
@@ -2411,16 +2441,9 @@ export default function Settings({ me }) {
<span className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</span> <span className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</span>
)} )}
{!m.enabled && <span className="mod-off">Ausgeschaltet</span>} {!m.enabled && <span className="mod-off">Ausgeschaltet</span>}
{m.tab && ( <button className="mod-link" onClick={() => setOpenModule(m.id)}>
<button Einrichten
className="mod-link" </button>
onClick={() => (m.missing.length > 0
? jumpToMissing(m.missing[0], m.tab)
: setTab(m.tab))}
>
Einstellungen
</button>
)}
</div> </div>
</div> </div>
); );
@@ -2434,7 +2457,8 @@ export default function Settings({ me }) {
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Texte</h2> <h2 className="settings-title">// Texte</h2>
<p className="section-intro"> <p className="section-intro">
Alles, was der Bot nach außen schreibt. Zeile anklicken zum Bearbeiten Alle Texte am Stück wer nur die einer Funktion sucht, findet sie
auch auf deren Modul-Seite. Zeile anklicken zum Bearbeiten
die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein, die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein,
ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
bedeutet: wieder der Standardtext. bedeutet: wieder der Standardtext.
@@ -2541,7 +2565,8 @@ export default function Settings({ me }) {
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Werte</h2> <h2 className="settings-title">// Werte</h2>
<p className="section-intro"> <p className="section-intro">
Zahlen, die das Verhalten steuern wie viel XP eine Nachricht bringt, Alle Werte am Stück; jeder steht zusätzlich auf der Seite seiner
Funktion. Zahlen, die das Verhalten steuern wie viel XP eine Nachricht bringt,
wie oft geprüft wird, wann gepostet wird. Geänderte Intervalle greifen ab 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 dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte
Bereich; angepasste Werte lassen sich einzeln zurücksetzen. Bereich; angepasste Werte lassen sich einzeln zurücksetzen.
+49
View File
@@ -726,6 +726,55 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.btn-create:hover:not(:disabled) { background: rgba(245, 197, 24, .1); } .btn-create:hover:not(:disabled) { background: rgba(245, 197, 24, .1); }
.btn-create:disabled { opacity: .4; cursor: default; } .btn-create:disabled { opacity: .4; cursor: default; }
/* ── MODUL-SEITE ───────────────────────────────────── */
/* Name und Symbol auf der Karte öffnen die Seite der Funktion */
.mod-open {
display: flex; align-items: flex-start; gap: .8rem;
flex: 1; min-width: 0; text-align: left;
background: none; border: 0; padding: 0; cursor: pointer;
}
.mod-open:hover .mod-name { color: var(--neon); }
.modpage-back {
background: none; border: 0; padding: 0; margin-bottom: 1.2rem;
font-family: var(--mono); font-size: .62rem;
letter-spacing: .15em; text-transform: uppercase;
color: var(--muted2); cursor: pointer;
transition: color .15s;
}
.modpage-back:hover { color: var(--neon); }
.modpage-head {
display: flex; align-items: flex-start; gap: 1rem;
padding-bottom: 1.2rem; margin-bottom: 1.2rem;
border-bottom: 1px solid var(--border);
}
.modpage-icon {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 3rem; height: 3rem; border-radius: 11px;
background: var(--bg3); border: 1px solid rgba(245, 197, 24, .3);
color: var(--neon);
}
.modpage-titles { flex: 1; min-width: 0; }
.modpage-name {
font-family: var(--display); font-size: 1.7rem;
letter-spacing: .04em; color: var(--text); margin: 0 0 .2rem;
}
.modpage-desc { color: var(--muted); font-weight: 300; line-height: 1.5; margin: 0; }
/* Abschnitte innerhalb der Seite — flacher als die großen Bereichs-Karten */
.modpage-block { margin-bottom: 1.8rem; }
.modpage-block:last-child { margin-bottom: 0; }
.modpage-block > .settings-title {
font-size: .66rem; color: var(--muted);
margin-bottom: .9rem;
}
.modpage-block > .settings-title::after {
background: linear-gradient(90deg, rgba(255, 255, 255, .12), transparent);
}
/* Die Seite ist schmaler als das zweispaltige Raster — Formulare lesen sich so besser */
.modpage { max-width: 60rem; }
/* Grundgerüst-Assistent: Vorschau und Ergebnis */ /* Grundgerüst-Assistent: Vorschau und Ergebnis */
.setup-plan { .setup-plan {
border: 1px solid var(--border); border-radius: 12px; border: 1px solid var(--border); border-radius: 12px;
+90
View File
@@ -25,12 +25,19 @@ export const MODULES = [
desc: 'Entwicklungs-Berichte als gebrandetes Embed posten und durchsuchbar archivieren.', desc: 'Entwicklungs-Berichte als gebrandetes Embed posten und durchsuchbar archivieren.',
tab: 'feeds', default: true, tab: 'feeds', default: true,
requires: [{ setting: 'devlog_channel_id', label: 'Devlog-Kanal', create: { name: 'dev-log', topic: 'Entwicklungs-Updates vom Bot' } }], requires: [{ setting: 'devlog_channel_id', label: 'Devlog-Kanal', create: { name: 'dev-log', topic: 'Entwicklungs-Updates vom Bot' } }],
fields: [
{ setting: 'devlog_ping_role_id', label: 'Ping-Rolle', art: 'rolle', hint: 'wird bei jedem Devlog erwähnt; leer = kein Ping' },
],
}, },
{ {
id: 'commit_feed', group: 'inhalte', name: 'Commit-Feed', id: 'commit_feed', group: 'inhalte', name: 'Commit-Feed',
desc: 'Pushes aus Gitea landen als Embed im Kanal. Archiviert wird unabhängig davon immer.', desc: 'Pushes aus Gitea landen als Embed im Kanal. Archiviert wird unabhängig davon immer.',
tab: 'feeds', setting: 'commit_feed_enabled', defaultOn: true, tab: 'feeds', setting: 'commit_feed_enabled', defaultOn: true,
requires: [{ setting: 'commit_channel_id', label: 'Commit-Kanal', create: { name: 'commits', privat: true, topic: 'Pushes aus Gitea' } }], requires: [{ setting: 'commit_channel_id', label: 'Commit-Kanal', create: { name: 'commits', privat: true, topic: 'Pushes aus Gitea' } }],
fields: [
{ setting: 'commit_branch_filter', label: 'Branch-Filter', art: 'text', hint: 'kommagetrennt, leer = alle' },
{ setting: 'ignored_repos', label: 'Ignorierte Repos', art: 'text', hint: 'kommagetrennt, volle Namen' },
],
}, },
{ {
id: 'releases', group: 'inhalte', name: 'Release-Ankündigungen', id: 'releases', group: 'inhalte', name: 'Release-Ankündigungen',
@@ -54,6 +61,10 @@ export const MODULES = [
desc: 'Meldet Livestreams und neue Videos.', desc: 'Meldet Livestreams und neue Videos.',
tab: 'system', default: true, tab: 'system', default: true,
requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }], requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }],
fields: [
{ setting: 'twitch_channel', label: 'Twitch-Kanal', art: 'text', hint: 'Login-Name, z. B. d4rkst3r' },
{ setting: 'youtube_channel_id', label: 'YouTube-Kanal', art: 'text', hint: 'Kanal-ID (UC…)' },
],
}, },
/* ── Community ────────────────────────────────── */ /* ── Community ────────────────────────────────── */
@@ -61,12 +72,19 @@ export const MODULES = [
id: 'levels', group: 'community', name: 'Level-System', id: 'levels', group: 'community', name: 'Level-System',
desc: 'XP fürs Mitreden, Rollen-Belohnungen und öffentliche Bestenliste.', desc: 'XP fürs Mitreden, Rollen-Belohnungen und öffentliche Bestenliste.',
tab: 'community', setting: 'levels_enabled', defaultOn: false, tab: 'community', setting: 'levels_enabled', defaultOn: false,
fields: [
{ setting: 'levels_announce', label: 'Level-Up im Kanal feiern', art: 'schalter' },
{ setting: 'level_rewards', label: 'Rollen-Belohnungen', art: 'text', hint: 'Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678' },
],
}, },
{ {
id: 'starboard', group: 'community', name: 'Starboard', id: 'starboard', group: 'community', name: 'Starboard',
desc: 'Nachrichten mit genug Sternen landen in einem Best-of-Kanal.', desc: 'Nachrichten mit genug Sternen landen in einem Best-of-Kanal.',
tab: 'community', default: true, tab: 'community', default: true,
requires: [{ setting: 'starboard_channel_id', label: 'Starboard-Kanal', create: { name: 'best-of', topic: 'Nachrichten mit genug Sternen' } }], requires: [{ setting: 'starboard_channel_id', label: 'Starboard-Kanal', create: { name: 'best-of', topic: 'Nachrichten mit genug Sternen' } }],
fields: [
{ setting: 'starboard_threshold', label: 'Schwellwert', art: 'zahl', hint: 'so viele ⭐ braucht eine Nachricht' },
],
}, },
{ {
id: 'gallery', group: 'community', name: 'Screenshot-Galerie', id: 'gallery', group: 'community', name: 'Screenshot-Galerie',
@@ -90,6 +108,9 @@ export const MODULES = [
desc: 'Gratulation am großen Tag, optional mit Tages-Rolle.', desc: 'Gratulation am großen Tag, optional mit Tages-Rolle.',
tab: 'community', default: true, tab: 'community', default: true,
requires: [{ setting: 'birthday_channel_id', label: 'Geburtstags-Kanal', create: { name: 'geburtstage', topic: 'Der Bot gratuliert hier' } }], requires: [{ setting: 'birthday_channel_id', label: 'Geburtstags-Kanal', create: { name: 'geburtstage', topic: 'Der Bot gratuliert hier' } }],
fields: [
{ setting: 'birthday_role_id', label: 'Tages-Rolle', art: 'rolle', hint: 'wird am nächsten Morgen wieder entfernt' },
],
}, },
{ {
id: 'events', group: 'community', name: 'Event-Ankündigungen', id: 'events', group: 'community', name: 'Event-Ankündigungen',
@@ -102,21 +123,25 @@ export const MODULES = [
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.', desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
tab: 'community', default: true, tab: 'community', default: true,
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }], requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }],
tool: { tab: 'community', label: 'Playtester-Liste' },
}, },
{ {
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys', id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.', desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
tab: 'community', default: true, tab: 'community', default: true,
tool: { tab: 'community', label: 'Schlüssel-Pool' },
}, },
{ {
id: 'role_menus', group: 'community', name: 'Rollen-Menüs', id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
desc: 'Selbstbedienungs-Rollen per Knopf — im Discord und im Profil.', desc: 'Selbstbedienungs-Rollen per Knopf — im Discord und im Profil.',
tab: 'rollen', default: true, tab: 'rollen', default: true,
tool: { tab: 'rollen', label: 'Rollen-Menüs bauen' },
}, },
{ {
id: 'applications', group: 'community', name: 'Bewerbungen', id: 'applications', group: 'community', name: 'Bewerbungen',
desc: 'Formulare als Eingabefenster, Prüfung mit Annehmen/Ablehnen.', desc: 'Formulare als Eingabefenster, Prüfung mit Annehmen/Ablehnen.',
tab: 'bewerbungen', default: true, tab: 'bewerbungen', default: true,
tool: { tab: 'bewerbungen', label: 'Formulare bearbeiten' },
}, },
/* ── Moderation & Support ─────────────────────── */ /* ── Moderation & Support ─────────────────────── */
@@ -124,6 +149,7 @@ export const MODULES = [
id: 'moderation', group: 'moderation', name: 'Moderation', id: 'moderation', group: 'moderation', name: 'Moderation',
desc: 'Verwarnen, Auszeiten, Aufräumen — mit Verlauf und Protokoll.', desc: 'Verwarnen, Auszeiten, Aufräumen — mit Verlauf und Protokoll.',
tab: 'support', default: true, tab: 'support', default: true,
tool: { tab: 'support', label: 'Moderation' },
}, },
{ {
id: 'modlog', group: 'moderation', name: 'Protokoll', id: 'modlog', group: 'moderation', name: 'Protokoll',
@@ -148,6 +174,18 @@ export const MODULES = [
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.', desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
tab: 'support', default: true, tab: 'support', default: true,
requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }], requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }],
fields: [
{ setting: 'welcome_card_enabled', label: 'Karte rendern', art: 'schalter', hint: 'aus = nur das Text-Embed' },
{ setting: 'welcome_card_kicker', label: 'Kleine Zeile', art: 'text', hint: 'über dem Namen — leer = weglassen' },
{ setting: 'welcome_card_sub', label: 'Zeile darunter', art: 'text', hint: '{count} wird zur Mitglieds-Nummer' },
{ setting: 'welcome_card_image', label: 'Hintergrundbild', art: 'text', hint: 'https-Adresse, 900×300 passt genau' },
{ setting: 'welcome_card_bg', label: 'Hintergrund', art: 'farbe' },
{ setting: 'welcome_card_accent', label: 'Akzent 1', art: 'farbe' },
{ setting: 'welcome_card_accent2', label: 'Akzent 2', art: 'farbe' },
{ setting: 'welcome_card_text', label: 'Name', art: 'farbe' },
{ setting: 'welcome_card_watermark', label: 'Markenname im Hintergrund', art: 'schalter' },
],
vorschau: 'welcome',
}, },
{ {
id: 'autorole', group: 'moderation', name: 'Auto-Rolle', id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
@@ -167,6 +205,9 @@ export const MODULES = [
// System-Bereich — sonst landet „Einrichten" an der falschen Stelle. // System-Bereich — sonst landet „Einrichten" an der falschen Stelle.
tab: 'brand', default: true, tab: 'brand', default: true,
requires: [{ setting: 'gitea_api_token', label: 'Gitea-Token' }], requires: [{ setting: 'gitea_api_token', label: 'Gitea-Token' }],
fields: [
{ setting: 'bug_report_repo', label: 'Ziel-Repository', art: 'text', hint: 'Besitzer/Name, z. B. D4rkst3r/EcoGame' },
],
}, },
/* ── Server & Technik ─────────────────────────── */ /* ── Server & Technik ─────────────────────────── */
@@ -174,12 +215,20 @@ export const MODULES = [
id: 'server_monitor', group: 'server', name: 'Server-Monitor', id: 'server_monitor', group: 'server', name: 'Server-Monitor',
desc: 'Live-Status der Game-Server mit Verlauf und Ausfall-Alarm.', desc: 'Live-Status der Game-Server mit Verlauf und Ausfall-Alarm.',
tab: 'server', default: true, tab: 'server', default: true,
fields: [
{ setting: 'status_channel_id', label: 'Status-Kanal', art: 'kanal', hint: 'hier steht das Live-Embed' },
{ setting: 'server_alert_channel_id', label: 'Alarm-Kanal', art: 'kanal', hint: '🚨 down / ✅ wieder online' },
],
tool: { tab: 'server', label: 'Game-Server verwalten' },
}, },
{ {
id: 'watchdog', group: 'server', name: 'Erreichbarkeits-Wächter', id: 'watchdog', group: 'server', name: 'Erreichbarkeits-Wächter',
desc: 'Prüft hinterlegte Adressen und meldet Ausfälle per Direktnachricht.', desc: 'Prüft hinterlegte Adressen und meldet Ausfälle per Direktnachricht.',
tab: 'system', default: true, tab: 'system', default: true,
requires: [{ setting: 'watchdog_urls', label: 'Adressen' }], requires: [{ setting: 'watchdog_urls', label: 'Adressen' }],
fields: [
{ setting: 'watchdog_urls', label: 'Zu prüfende Adressen', art: 'text', hint: 'kommagetrennt, mit http(s)' },
],
}, },
{ {
id: 'temp_voice', group: 'server', name: 'Temporäre Sprachkanäle', id: 'temp_voice', group: 'server', name: 'Temporäre Sprachkanäle',
@@ -191,6 +240,7 @@ export const MODULES = [
id: 'triggers', group: 'server', name: 'Auto-Antworten', id: 'triggers', group: 'server', name: 'Auto-Antworten',
desc: 'Der Bot antwortet auf hinterlegte Schlüsselwörter.', desc: 'Der Bot antwortet auf hinterlegte Schlüsselwörter.',
tab: 'composer', default: true, tab: 'composer', default: true,
tool: { tab: 'composer', label: 'Auto-Antworten pflegen' },
}, },
{ {
id: 'reminders', group: 'server', name: 'Erinnerungen', id: 'reminders', group: 'server', name: 'Erinnerungen',
@@ -201,16 +251,21 @@ export const MODULES = [
id: 'scheduled_posts', group: 'server', name: 'Geplante Beiträge', id: 'scheduled_posts', group: 'server', name: 'Geplante Beiträge',
desc: 'Nachrichten zu festen Zeiten, einmalig oder wiederkehrend.', desc: 'Nachrichten zu festen Zeiten, einmalig oder wiederkehrend.',
tab: 'composer', default: true, tab: 'composer', default: true,
tool: { tab: 'composer', label: 'Geplante Beiträge' },
}, },
{ {
id: 'tags', group: 'server', name: 'Textbausteine', id: 'tags', group: 'server', name: 'Textbausteine',
desc: 'Gespeicherte Texte per Befehl abrufen.', desc: 'Gespeicherte Texte per Befehl abrufen.',
tab: 'composer', default: true, tab: 'composer', default: true,
tool: { tab: 'composer', label: 'Textbausteine' },
}, },
{ {
id: 'backups', group: 'server', name: 'Datenbank-Sicherung', id: 'backups', group: 'server', name: 'Datenbank-Sicherung',
desc: 'Nächtliche Sicherung mit Aufbewahrung über 14 Tage.', desc: 'Nächtliche Sicherung mit Aufbewahrung über 14 Tage.',
tab: 'system', setting: 'backup_enabled', defaultOn: true, tab: 'system', setting: 'backup_enabled', defaultOn: true,
fields: [
{ setting: 'backup_channel_id', label: 'Kanal für die Sicherung', art: 'kanal', hint: 'leer = nur lokal' },
],
}, },
{ {
id: 'repo_backups', group: 'server', name: 'Repository-Sicherung', id: 'repo_backups', group: 'server', name: 'Repository-Sicherung',
@@ -221,6 +276,9 @@ export const MODULES = [
id: 'member_gate', group: 'server', name: 'Login nur für Mitglieder', id: 'member_gate', group: 'server', name: 'Login nur für Mitglieder',
desc: 'Nur wer auf dem Discord-Server ist, kann sich im Web anmelden.', desc: 'Nur wer auf dem Discord-Server ist, kann sich im Web anmelden.',
tab: 'system', setting: 'member_gate_enabled', defaultOn: true, tab: 'system', setting: 'member_gate_enabled', defaultOn: true,
fields: [
{ setting: 'discord_invite_url', label: 'Einladungs-Link', art: 'text', hint: 'bekommt, wer nicht auf dem Server ist' },
],
}, },
]; ];
@@ -270,6 +328,38 @@ export function creationPlan(setting) {
return null; return null;
} }
/**
* Alles, was zu einem Modul gehört — für seine eigene Seite im Panel.
* Bringt Kanäle/Rollen, Zusatzfelder und den Verweis auf ein Werkzeug
* zusammen; Texte und Werte holt die API aus ihren eigenen Registern.
*/
export function moduleDetail(id) {
const mod = MODULES.find((m) => m.id === id);
if (!mod) return null;
return {
id: mod.id,
name: mod.name,
desc: mod.desc,
group: mod.group,
tab: mod.tab ?? null,
enabled: moduleEnabled(mod.id),
missing: moduleMissing(mod),
// Voraussetzungen zuerst — ohne sie tut das Modul nichts
felder: [
...(mod.requires ?? []).map((r) => ({
setting: r.setting,
label: r.label,
art: r.create?.rolle ? 'rolle' : 'kanal',
hint: null,
pflicht: true,
})),
...(mod.fields ?? []).map((f) => ({ ...f, hint: f.hint ?? null, pflicht: false })),
],
tool: mod.tool ?? null,
vorschau: mod.vorschau ?? null,
};
}
/** Alle Module mit Zustand — für das Webinterface */ /** Alle Module mit Zustand — für das Webinterface */
export function moduleStates() { export function moduleStates() {
return MODULES.map((mod) => ({ return MODULES.map((mod) => ({
+24 -20
View File
@@ -1,6 +1,9 @@
// Stellwerte: alle Zahlen, die vorher fest im Code standen — XP-Beträge, // Stellwerte: alle Zahlen, die vorher fest im Code standen — XP-Beträge,
// Wartezeiten, Prüf-Intervalle, Obergrenzen. // Wartezeiten, Prüf-Intervalle, Obergrenzen.
// //
// `module` verbindet einen Wert mit seiner Funktion — die Modul-Seite zeigt ihn
// dann dort, wo er hingehört, statt nur in der flachen Werte-Liste.
//
// Wie bei den Modulen und Vorlagen lebt der Standard im Code und die // Wie bei den Modulen und Vorlagen lebt der Standard im Code und die
// Datenbank enthält nur Abweichungen. `min`/`max` sind keine Kosmetik: sie // Datenbank enthält nur Abweichungen. `min`/`max` sind keine Kosmetik: sie
// verhindern, dass ein Vertipper den Bot in eine Prüfschleife im // verhindern, dass ein Vertipper den Bot in eine Prüfschleife im
@@ -17,94 +20,94 @@ export const TUNING_GROUPS = [
export const TUNING = [ export const TUNING = [
/* ── Level & Aktivität ────────────────────────────── */ /* ── Level & Aktivität ────────────────────────────── */
{ {
id: 'xp_min', group: 'level', label: 'XP pro Nachricht — mindestens', id: 'xp_min', module: 'levels', group: 'level', label: 'XP pro Nachricht — mindestens',
unit: 'XP', default: 15, min: 1, max: 500, unit: 'XP', default: 15, min: 1, max: 500,
}, },
{ {
id: 'xp_max', group: 'level', label: 'XP pro Nachricht — höchstens', id: 'xp_max', module: 'levels', group: 'level', label: 'XP pro Nachricht — höchstens',
hint: 'Der Bot würfelt zwischen beiden Werten.', hint: 'Der Bot würfelt zwischen beiden Werten.',
unit: 'XP', default: 25, min: 1, max: 500, unit: 'XP', default: 25, min: 1, max: 500,
}, },
{ {
id: 'xp_cooldown', group: 'level', label: 'Wartezeit zwischen zwei XP-Gutschriften', id: 'xp_cooldown', module: 'levels', group: 'level', label: 'Wartezeit zwischen zwei XP-Gutschriften',
hint: 'Verhindert, dass Vielschreiber das Level-System leerlaufen lassen.', hint: 'Verhindert, dass Vielschreiber das Level-System leerlaufen lassen.',
unit: 'Sekunden', default: 60, min: 5, max: 3600, unit: 'Sekunden', default: 60, min: 5, max: 3600,
}, },
{ {
id: 'trigger_cooldown', group: 'level', label: 'Wartezeit für Auto-Antworten', id: 'trigger_cooldown', module: 'triggers', group: 'level', label: 'Wartezeit für Auto-Antworten',
hint: 'Pro Schlüsselwort und Kanal — sonst antwortet der Bot sich in Grund und Boden.', hint: 'Pro Schlüsselwort und Kanal — sonst antwortet der Bot sich in Grund und Boden.',
unit: 'Sekunden', default: 30, min: 5, max: 3600, unit: 'Sekunden', default: 30, min: 5, max: 3600,
}, },
/* ── Wann der Bot postet ──────────────────────────── */ /* ── Wann der Bot postet ──────────────────────────── */
{ {
id: 'birthday_hour', group: 'zeiten', label: 'Geburtstags-Gratulation ab', id: 'birthday_hour', module: 'birthdays', group: 'zeiten', label: 'Geburtstags-Gratulation ab',
hint: 'Uhrzeit in Europe/Berlin. Gratuliert wird einmal am Tag.', hint: 'Uhrzeit in Europe/Berlin. Gratuliert wird einmal am Tag.',
unit: 'Uhr', default: 9, min: 0, max: 23, unit: 'Uhr', default: 9, min: 0, max: 23,
}, },
{ {
id: 'recap_weekday', group: 'zeiten', label: 'Wochen-Rückblick am', id: 'recap_weekday', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick am',
hint: '0 = Sonntag, 1 = Montag … 6 = Samstag.', hint: '0 = Sonntag, 1 = Montag … 6 = Samstag.',
unit: 'Wochentag', default: 0, min: 0, max: 6, unit: 'Wochentag', default: 0, min: 0, max: 6,
}, },
{ {
id: 'recap_hour', group: 'zeiten', label: 'Wochen-Rückblick ab', id: 'recap_hour', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick ab',
unit: 'Uhr', default: 20, min: 0, max: 23, unit: 'Uhr', default: 20, min: 0, max: 23,
}, },
/* ── Prüf-Intervalle ──────────────────────────────── */ /* ── Prüf-Intervalle ──────────────────────────────── */
{ {
id: 'monitor_interval', group: 'pruefung', label: 'Game-Server prüfen alle', id: 'monitor_interval', module: 'server_monitor', group: 'pruefung', label: 'Game-Server prüfen alle',
unit: 'Minuten', default: 2, min: 1, max: 120, unit: 'Minuten', default: 2, min: 1, max: 120,
}, },
{ {
id: 'monitor_timeout', group: 'pruefung', label: 'Game-Server — Antwort abwarten', id: 'monitor_timeout', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Antwort abwarten',
unit: 'Sekunden', default: 8, min: 2, max: 60, unit: 'Sekunden', default: 8, min: 2, max: 60,
}, },
{ {
id: 'monitor_fails', group: 'pruefung', label: 'Game-Server — Alarm nach', id: 'monitor_fails', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Alarm nach',
hint: 'Fehlversuchen in Folge. 1 meldet jeden Aussetzer, höher meldet nur echte Ausfälle.', hint: 'Fehlversuchen in Folge. 1 meldet jeden Aussetzer, höher meldet nur echte Ausfälle.',
unit: 'Fehlversuchen', default: 2, min: 1, max: 10, unit: 'Fehlversuchen', default: 2, min: 1, max: 10,
}, },
{ {
id: 'watchdog_interval', group: 'pruefung', label: 'Adressen prüfen alle', id: 'watchdog_interval', module: 'watchdog', group: 'pruefung', label: 'Adressen prüfen alle',
unit: 'Minuten', default: 2, min: 1, max: 120, unit: 'Minuten', default: 2, min: 1, max: 120,
}, },
{ {
id: 'watchdog_timeout', group: 'pruefung', label: 'Adressen — Antwort abwarten', id: 'watchdog_timeout', module: 'watchdog', group: 'pruefung', label: 'Adressen — Antwort abwarten',
unit: 'Sekunden', default: 10, min: 2, max: 60, unit: 'Sekunden', default: 10, min: 2, max: 60,
}, },
{ {
id: 'watchdog_fails', group: 'pruefung', label: 'Adressen — Alarm nach', id: 'watchdog_fails', module: 'watchdog', group: 'pruefung', label: 'Adressen — Alarm nach',
unit: 'Fehlversuchen', default: 2, min: 1, max: 10, unit: 'Fehlversuchen', default: 2, min: 1, max: 10,
}, },
{ {
id: 'social_interval', group: 'pruefung', label: 'Twitch & YouTube prüfen alle', id: 'social_interval', module: 'social', group: 'pruefung', label: 'Twitch & YouTube prüfen alle',
hint: 'Zu kurz bringt nichts — YouTube-Feeds aktualisieren sich ohnehin nur alle paar Minuten.', hint: 'Zu kurz bringt nichts — YouTube-Feeds aktualisieren sich ohnehin nur alle paar Minuten.',
unit: 'Minuten', default: 5, min: 1, max: 180, unit: 'Minuten', default: 5, min: 1, max: 180,
}, },
{ {
id: 'birthday_interval', group: 'pruefung', label: 'Geburtstage prüfen alle', id: 'birthday_interval', module: 'birthdays', group: 'pruefung', label: 'Geburtstage prüfen alle',
unit: 'Minuten', default: 15, min: 1, max: 120, unit: 'Minuten', default: 15, min: 1, max: 120,
}, },
/* ── Obergrenzen ──────────────────────────────────── */ /* ── Obergrenzen ──────────────────────────────────── */
{ {
id: 'ticket_transcript_max', group: 'grenzen', label: 'Nachrichten im Ticket-Protokoll', id: 'ticket_transcript_max', module: 'tickets', group: 'grenzen', label: 'Nachrichten im Ticket-Protokoll',
hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.', hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.',
unit: 'Nachrichten', default: 500, min: 50, max: 2000, unit: 'Nachrichten', default: 500, min: 50, max: 2000,
}, },
{ {
id: 'gallery_max_images', group: 'grenzen', label: 'Bilder je Galerie-Beitrag', id: 'gallery_max_images', module: 'gallery', group: 'grenzen', label: 'Bilder je Galerie-Beitrag',
unit: 'Bilder', default: 4, min: 1, max: 10, unit: 'Bilder', default: 4, min: 1, max: 10,
}, },
{ {
id: 'commits_shown', group: 'grenzen', label: 'Commits je Push-Embed', id: 'commits_shown', module: 'commit_feed', group: 'grenzen', label: 'Commits je Push-Embed',
hint: 'Der Rest wird als „… und N weitere" zusammengefasst.', hint: 'Der Rest wird als „… und N weitere" zusammengefasst.',
unit: 'Commits', default: 10, min: 1, max: 20, unit: 'Commits', default: 10, min: 1, max: 20,
}, },
{ {
id: 'thread_archive_days', group: 'grenzen', label: 'Threads archivieren nach', id: 'thread_archive_days', module: 'tickets', group: 'grenzen', label: 'Threads archivieren nach',
hint: 'Für Ticket- und Modmail-Threads. Discord erlaubt 1, 3 oder 7 Tage.', hint: 'Für Ticket- und Modmail-Threads. Discord erlaubt 1, 3 oder 7 Tage.',
unit: 'Tagen', default: 7, min: 1, max: 7, choices: [1, 3, 7], unit: 'Tagen', default: 7, min: 1, max: 7, choices: [1, 3, 7],
}, },
@@ -167,7 +170,8 @@ export function tuningStates() {
return TUNING.map((t) => { return TUNING.map((t) => {
const custom = getSetting(`tune_${t.id}`); const custom = getSetting(`tune_${t.id}`);
return { return {
id: t.id, label: t.label, hint: t.hint ?? null, group: t.group, id: t.id, label: t.label, hint: t.hint ?? null,
group: t.group, module: t.module ?? null,
unit: t.unit, min: t.min, max: t.max, unit: t.unit, min: t.min, max: t.max,
choices: t.choices ?? null, choices: t.choices ?? null,
default: t.default, default: t.default,
+14 -1
View File
@@ -21,7 +21,7 @@ import {
savePage, getPage, listPages, listPublishedPages, deletePage, savePage, getPage, listPages, listPublishedPages, deletePage,
} from '../db.js'; } from '../db.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, setModuleEnabled, MODULE_GROUPS, MODULES } from '../modules.js'; import { moduleStates, moduleDetail, setModuleEnabled, MODULE_GROUPS, MODULES } from '../modules.js';
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { tuningStates, setTuning, TUNING_GROUPS } from '../tuning.js'; import { tuningStates, setTuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
@@ -193,6 +193,19 @@ export function registerApiRoutes(app, client) {
return { modules: moduleStates(), groups: MODULE_GROUPS }; return { modules: moduleStates(), groups: MODULE_GROUPS };
}); });
// Alles zu einer Funktion an einem Ort: ihre Kanäle und Felder, ihre Texte,
// ihre Werte. Genau das zeigt die Modul-Seite.
app.get('/api/modules/:id', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
const detail = moduleDetail(String(request.params.id));
if (!detail) return reply.code(404).send({ error: 'Modul unbekannt' });
return {
...detail,
texte: templateStates().filter((t) => t.module === detail.id),
werte: tuningStates().filter((w) => w.module === detail.id),
};
});
app.put('/api/modules/:id', async (request, reply) => { app.put('/api/modules/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return; if (requireScope(request, reply, 'settings')) return;
const id = String(request.params.id); const id = String(request.params.id);