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
+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,
} from '../icons.jsx';
import { moduleIcon } from '../module-icons.jsx';
import ModulePage from '../components/ModulePage.jsx';
import { findSettings, SETTING_INDEX } from '../setting-index.js';
import { Markdown } from '../markdown.jsx';
@@ -245,10 +246,11 @@ function fmtBytes(b) {
export default function Settings({ me }) {
// Tab steht in der Adresse (#texte), damit Neuladen und Links den Bereich behalten
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) => {
setTabState(id);
setOpenModuleState(null);
window.history.replaceState(null, '', `#${id}`);
};
const [tabSearch, setTabSearch] = useState('');
@@ -304,6 +306,14 @@ export default function Settings({ me }) {
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
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
// Grundgerüst-Assistent
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
useEffect(() => {
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);
};
window.addEventListener('hashchange', onHash);
@@ -2355,7 +2365,21 @@ export default function Settings({ me }) {
];
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">
<h2 className="settings-title">// Module</h2>
<p className="section-intro">
@@ -2387,11 +2411,17 @@ export default function Settings({ me }) {
return (
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
<div className="mod-head">
<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
className="mod-open"
title={`${m.name} einrichten`}
onClick={() => setOpenModule(m.id)}
>
<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
className={`mod-switch${m.enabled ? ' on' : ''}`}
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>
)}
{!m.enabled && <span className="mod-off">Ausgeschaltet</span>}
{m.tab && (
<button
className="mod-link"
onClick={() => (m.missing.length > 0
? jumpToMissing(m.missing[0], m.tab)
: setTab(m.tab))}
>
Einstellungen
</button>
)}
<button className="mod-link" onClick={() => setOpenModule(m.id)}>
Einrichten
</button>
</div>
</div>
);
@@ -2434,7 +2457,8 @@ export default function Settings({ me }) {
<div className="settings-section">
<h2 className="settings-title">// Texte</h2>
<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,
ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
bedeutet: wieder der Standardtext.
@@ -2541,7 +2565,8 @@ export default function Settings({ me }) {
<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,
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
dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte
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: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 */
.setup-plan {
border: 1px solid var(--border); border-radius: 12px;