diff --git a/README.md b/README.md index 10d335f..e2bf816 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ Share Tech Mono, selbst gehostet). ### 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 — 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 diff --git a/frontend/src/components/ModulePage.jsx b/frontend/src/components/ModulePage.jsx new file mode 100644 index 0000000..de9bc9a --- /dev/null +++ b/frontend/src/components/ModulePage.jsx @@ -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 ( + + ); + } + if (feld.art === 'schalter') { + return ( + + ); + } + if (feld.art === 'farbe') { + return ( + + ); + } + return ( + 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

Diese Funktion konnte nicht geladen werden.

; + if (!detail) return

Lade …

; + + 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 ( +
+ + +
+ +
+

{detail.name}

+

{detail.desc}

+
+ +
+ + {detail.enabled && detail.missing.length > 0 && ( +

+ Fehlt noch: {detail.missing.map((m) => m.label).join(', ')} +

+ )} + {detail.enabled && detail.missing.length === 0 && ( +

+ Einsatzbereit +

+ )} + + {/* Kanäle, Rollen und alles Weitere */} + {detail.felder.length > 0 && ( +
+

Einstellungen

+ {detail.felder.filter((f) => f.art !== 'farbe').map((f) => ( +
+ {f.art !== 'schalter' && ( + + )} +
+ 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 && ( + + )} +
+
+ ))} + + {detail.felder.some((f) => f.art === 'farbe') && ( +
+ {detail.felder.filter((f) => f.art === 'farbe').map((f) => ( + setForm({ ...form, [f.setting]: v })} + PickSelect={PickSelect} + channelItems={channelItems} + roleItems={roleItems} + /> + ))} +
+ )} +
+ )} + + {/* Vorschau, wo es eine gibt */} + {detail.vorschau === 'welcome' && form.welcome_card_enabled && ( +
+

Vorschau

+ Vorschau der Willkommens-Karte +
+ )} + + {/* Texte dieser Funktion */} + {detail.texte.length > 0 && ( +
+

Texte

+
+ {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 ( +
+ + {offen && ( +
+