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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user