Files
d4rkbot/frontend/src/components/ModulePage.jsx
T
D4rkst3randClaude Fable 5 c88926aba0
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Modul-Seiten: alles zu einer Funktion an einem Ort
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>
2026-07-31 21:54:31 +02:00

343 lines
17 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}