Zwei Stellen, an denen die Marke bisher fehlte. Willkommens-Karte: Das Wasserzeichen war der Markenname als Text mit 5 % Deckkraft — jetzt der Katzenkopf. Die Datei wird einmal beim Start gelesen statt bei jedem Beitritt, und fehlt sie, rendert die Karte wie bisher ohne Wasserzeichen. Der Schalter heisst entsprechend "Logo im Hintergrund". Link-Vorschau: og:image war nur gesetzt, wenn ein Devlog einen Screenshot hatte. Startseite, Roadmap, Funktionen und jedes Devlog ohne Bild kamen in Discord als graue Textzeile. Jetzt faellt alles ohne eigenes Bild auf og-default.png zurueck (1200x630, Lockup auf Schwarz mit Akzentstreifen), und twitter:card ist durchgehend summary_large_image. Die Laufzeit-Datei liegt unter src/bot/assets/, nicht in docs/ — das Dockerfile kopiert nur src/ und frontend/dist/. Geprueft: librsvg zeichnet das eingebettete <image> wirklich (42726 Pixel unterscheiden sich gegen eine Fassung ohne), das OG-Bild landet im dist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
413 lines
19 KiB
JavaScript
413 lines
19 KiB
JavaScript
// Modul-Register: eine Stelle, an der alle Funktionen des Bots stehen.
|
||
//
|
||
// Jedes Modul hat einen Schalter im Webinterface. Damit nichts kaputtgeht,
|
||
// nutzen Funktionen mit einem bereits vorhandenen Schalter (etwa das
|
||
// Level-System) weiterhin denselben Einstellungs-Namen — es entsteht also
|
||
// keine zweite Wahrheit.
|
||
//
|
||
// `requires` beschreibt, was gesetzt sein muss, damit ein Modul wirklich
|
||
// arbeiten kann. Fehlt etwas, zeigt das Panel „noch nicht einsatzbereit"
|
||
// statt das Modul stillschweigend nichts tun zu lassen.
|
||
import { getSetting, setSetting } from './db.js';
|
||
import { effectiveSetting } from './runtime-settings.js';
|
||
|
||
export const MODULE_GROUPS = [
|
||
{ id: 'inhalte', label: 'Inhalte & Feeds' },
|
||
{ id: 'community', label: 'Community' },
|
||
{ id: 'moderation', label: 'Moderation & Support' },
|
||
{ id: 'server', label: 'Server & Technik' },
|
||
];
|
||
|
||
export const MODULES = [
|
||
/* ── Inhalte & Feeds ──────────────────────────── */
|
||
{
|
||
id: 'devlogs', group: 'inhalte', name: 'Devlogs',
|
||
desc: 'Entwicklungs-Berichte als gebrandetes Embed posten und durchsuchbar archivieren.',
|
||
tab: 'feeds', default: true,
|
||
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',
|
||
desc: 'Pushes aus Gitea landen als Embed im Kanal. Archiviert wird unabhängig davon immer.',
|
||
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' } }],
|
||
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',
|
||
desc: 'Neue Releases werden angekündigt und landen im öffentlichen Changelog.',
|
||
tab: 'feeds', default: true,
|
||
requires: [{ setting: 'release_channel_id', label: 'Release-Kanal', create: { name: 'releases', topic: 'Neue Versionen' } }],
|
||
},
|
||
{
|
||
id: 'weekly_recap', group: 'inhalte', name: 'Wochen-Rückblick',
|
||
desc: 'Sonntags um 20 Uhr eine Zusammenfassung der Woche.',
|
||
tab: 'feeds', setting: 'weekly_recap_enabled', defaultOn: true,
|
||
requires: [{ setting: 'devlog_channel_id', label: 'Devlog-Kanal', create: { name: 'dev-log', topic: 'Entwicklungs-Updates vom Bot' } }],
|
||
},
|
||
{
|
||
id: 'devlog_threads', group: 'inhalte', name: 'Devlog-Threads',
|
||
desc: 'Unter jedem Devlog entsteht ein Diskussions-Thread.',
|
||
tab: 'feeds', setting: 'devlog_threads_enabled', defaultOn: true,
|
||
},
|
||
{
|
||
id: 'social', group: 'inhalte', name: 'Twitch & YouTube',
|
||
desc: 'Meldet Livestreams und neue Videos.',
|
||
tab: 'system', default: true,
|
||
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 ────────────────────────────────── */
|
||
{
|
||
id: 'levels', group: 'community', name: 'Level-System',
|
||
desc: 'XP fürs Mitreden, Rollen-Belohnungen und öffentliche Bestenliste.',
|
||
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',
|
||
desc: 'Nachrichten mit genug Sternen landen in einem Best-of-Kanal.',
|
||
tab: 'community', default: true,
|
||
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',
|
||
desc: 'Bilder aus einem Kanal erscheinen in der öffentlichen Galerie.',
|
||
tab: 'community', default: true,
|
||
requires: [{ setting: 'screenshot_channel_id', label: 'Screenshot-Kanal', create: { name: 'screenshots', topic: 'Bilder landen in der Galerie' } }],
|
||
},
|
||
{
|
||
id: 'voting', group: 'community', name: 'Feature-Wünsche',
|
||
desc: 'Wünsche einreichen und abstimmen — im Discord und auf der Roadmap.',
|
||
tab: 'community', default: true,
|
||
requires: [{ setting: 'voting_channel_id', label: 'Voting-Kanal', create: { name: 'ideen', topic: 'Wuensche und Abstimmungen' } }],
|
||
},
|
||
{
|
||
id: 'giveaways', group: 'community', name: 'Verlosungen',
|
||
desc: 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung möglich.',
|
||
tab: 'community', default: true,
|
||
},
|
||
{
|
||
id: 'birthdays', group: 'community', name: 'Geburtstage',
|
||
desc: 'Gratulation am großen Tag, optional mit Tages-Rolle.',
|
||
tab: 'community', default: true,
|
||
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',
|
||
desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.',
|
||
tab: 'system', default: true,
|
||
requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal', create: { name: 'events', topic: 'Playtests, Streams, Runden' } }],
|
||
},
|
||
{
|
||
id: 'playtester', group: 'community', name: 'Playtester-Programm',
|
||
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
|
||
tab: 'community', default: true,
|
||
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',
|
||
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
|
||
tab: 'community', default: true,
|
||
tool: { tab: 'community', label: 'Schlüssel-Pool' },
|
||
},
|
||
{
|
||
id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
|
||
desc: 'Selbstbedienungs-Rollen per Knopf — im Discord und im Profil.',
|
||
tab: 'rollen', default: true,
|
||
tool: { tab: 'rollen', label: 'Rollen-Menüs bauen' },
|
||
},
|
||
{
|
||
id: 'applications', group: 'community', name: 'Bewerbungen',
|
||
desc: 'Formulare als Eingabefenster, Prüfung mit Annehmen/Ablehnen.',
|
||
tab: 'bewerbungen', default: true,
|
||
tool: { tab: 'bewerbungen', label: 'Formulare bearbeiten' },
|
||
},
|
||
|
||
/* ── Moderation & Support ─────────────────────── */
|
||
{
|
||
id: 'moderation', group: 'moderation', name: 'Moderation',
|
||
desc: 'Verwarnen, Auszeiten, Aufräumen — mit Verlauf und Protokoll.',
|
||
tab: 'support', default: true,
|
||
tool: { tab: 'support', label: 'Moderation' },
|
||
},
|
||
{
|
||
id: 'modlog', group: 'moderation', name: 'Protokoll',
|
||
desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.',
|
||
tab: 'support', default: true,
|
||
requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }],
|
||
},
|
||
{
|
||
id: 'modmail', group: 'moderation', name: 'Modmail',
|
||
desc: 'Direktnachrichten an den Bot landen als Thread beim Team.',
|
||
tab: 'support', default: true,
|
||
requires: [{ setting: 'modmail_channel_id', label: 'Modmail-Kanal', create: { name: 'modmail', privat: true, topic: 'Direktnachrichten an den Bot' } }],
|
||
},
|
||
{
|
||
id: 'tickets', group: 'moderation', name: 'Tickets',
|
||
desc: 'Private Threads per Knopf, beim Schließen ein Gesprächsprotokoll.',
|
||
tab: 'support', default: true,
|
||
requires: [{ setting: 'ticket_channel_id', label: 'Ticket-Kanal', create: { name: 'support', topic: 'Tickets per Knopf' } }],
|
||
},
|
||
{
|
||
id: 'welcome', group: 'moderation', name: 'Willkommens-Karten',
|
||
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
|
||
tab: 'support', default: true,
|
||
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: 'Logo im Hintergrund', art: 'schalter' },
|
||
],
|
||
vorschau: 'welcome',
|
||
},
|
||
{
|
||
id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
|
||
desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.',
|
||
tab: 'community', default: true,
|
||
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }],
|
||
},
|
||
{
|
||
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
|
||
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
|
||
tab: 'community', setting: 'sticky_roles_enabled', defaultOn: false,
|
||
},
|
||
{
|
||
id: 'bug_reports', group: 'moderation', name: 'Fehlermeldungen',
|
||
desc: 'Meldungen werden zu Issues im Repository, mit Rückmeldung beim Schließen.',
|
||
// Der Token steht im Brand-Bereich („Aus der Env verlagert"), nicht im
|
||
// System-Bereich — sonst landet „Einrichten" an der falschen Stelle.
|
||
tab: 'brand', default: true,
|
||
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 ─────────────────────────── */
|
||
{
|
||
id: 'server_monitor', group: 'server', name: 'Server-Monitor',
|
||
desc: 'Live-Status der Game-Server mit Verlauf und Ausfall-Alarm.',
|
||
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',
|
||
desc: 'Prüft hinterlegte Adressen und meldet Ausfälle per Direktnachricht.',
|
||
tab: 'system', default: true,
|
||
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',
|
||
desc: 'Beitritt zum Hub erzeugt einen eigenen Kanal mit Bedienfeld.',
|
||
tab: 'system', default: true,
|
||
requires: [{ setting: 'tempvoice_channel_id', label: 'Hub-Kanal', create: { name: 'Eigenen Kanal erstellen', sprache: true } }],
|
||
},
|
||
{
|
||
id: 'triggers', group: 'server', name: 'Auto-Antworten',
|
||
desc: 'Der Bot antwortet auf hinterlegte Schlüsselwörter.',
|
||
tab: 'composer', default: true,
|
||
tool: { tab: 'composer', label: 'Auto-Antworten pflegen' },
|
||
},
|
||
{
|
||
id: 'reminders', group: 'server', name: 'Erinnerungen',
|
||
desc: 'Mitglieder lassen sich per Direktnachricht erinnern.',
|
||
tab: 'system', default: true,
|
||
},
|
||
{
|
||
id: 'scheduled_posts', group: 'server', name: 'Geplante Beiträge',
|
||
desc: 'Nachrichten zu festen Zeiten, einmalig oder wiederkehrend.',
|
||
tab: 'composer', default: true,
|
||
tool: { tab: 'composer', label: 'Geplante Beiträge' },
|
||
},
|
||
{
|
||
id: 'tags', group: 'server', name: 'Textbausteine',
|
||
desc: 'Gespeicherte Texte per Befehl abrufen.',
|
||
tab: 'composer', default: true,
|
||
tool: { tab: 'composer', label: 'Textbausteine' },
|
||
},
|
||
{
|
||
id: 'backups', group: 'server', name: 'Datenbank-Sicherung',
|
||
desc: 'Nächtliche Sicherung mit Aufbewahrung über 14 Tage.',
|
||
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',
|
||
desc: 'Alle Gitea-Repositories als Bundle sichern.',
|
||
tab: 'system', setting: 'repo_backup_enabled', defaultOn: false,
|
||
},
|
||
{
|
||
id: 'member_gate', group: 'server', name: 'Login nur für Mitglieder',
|
||
desc: 'Nur wer auf dem Discord-Server ist, kann sich im Web anmelden.',
|
||
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' },
|
||
],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Funktionen, die in der Seitenleiste stehen — die paar, die man dauernd
|
||
* anfasst. Der Rest bleibt im Raster.
|
||
*
|
||
* Der Standard ist eine Vermutung, kein Gesetz: geändert wird er über den
|
||
* Stern auf der Modul-Karte. Deckel bei acht, damit die Leiste kurz bleibt —
|
||
* sonst ist der Gewinn gegenüber dem Raster wieder weg.
|
||
*/
|
||
export const DEFAULT_FAVORITES = ['devlogs', 'welcome', 'tickets', 'starboard', 'gallery'];
|
||
export const MAX_FAVORITES = 8;
|
||
|
||
/** Aktuelle Auswahl — nur existierende Module, in gespeicherter Reihenfolge */
|
||
export function moduleFavorites() {
|
||
const gespeichert = String(getSetting('module_favorites') ?? '').trim();
|
||
if (!gespeichert) return DEFAULT_FAVORITES.filter((id) => MODULES.some((m) => m.id === id));
|
||
// Ein einzelner Bindestrich heißt „bewusst keine" — sonst käme der Standard zurück
|
||
if (gespeichert === '-') return [];
|
||
return gespeichert
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter((id) => MODULES.some((m) => m.id === id))
|
||
.slice(0, MAX_FAVORITES);
|
||
}
|
||
|
||
/**
|
||
* Auswahl setzen.
|
||
* @returns {{ ok: true, favorites: string[] } | { ok: false, error: string }}
|
||
*/
|
||
export function setModuleFavorites(ids) {
|
||
if (!Array.isArray(ids)) return { ok: false, error: 'Liste erwartet' };
|
||
const gefiltert = [...new Set(ids.map(String))].filter((id) => MODULES.some((m) => m.id === id));
|
||
if (gefiltert.length > MAX_FAVORITES) {
|
||
return { ok: false, error: `Höchstens ${MAX_FAVORITES} Funktionen in der Seitenleiste` };
|
||
}
|
||
setSetting('module_favorites', gefiltert.length ? gefiltert.join(',') : '-');
|
||
return { ok: true, favorites: gefiltert };
|
||
}
|
||
|
||
/** Einstellungs-Name des Schalters für ein Modul */
|
||
function toggleKey(mod) {
|
||
return mod.setting ?? `module_${mod.id}`;
|
||
}
|
||
|
||
/** Läuft dieses Modul? (unabhängig davon, ob alles Nötige konfiguriert ist) */
|
||
export function moduleEnabled(id) {
|
||
const mod = MODULES.find((m) => m.id === id);
|
||
if (!mod) return true; // unbekannt → nicht blockieren
|
||
const value = getSetting(toggleKey(mod));
|
||
if (value === '1') return true;
|
||
if (value === '0') return false;
|
||
return mod.setting ? Boolean(mod.defaultOn) : mod.default !== false;
|
||
}
|
||
|
||
/** Schalter setzen */
|
||
export function setModuleEnabled(id, on) {
|
||
const mod = MODULES.find((m) => m.id === id);
|
||
if (!mod) return false;
|
||
setSetting(toggleKey(mod), on ? '1' : '0');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Was fehlt diesem Modul noch? (leere Liste = einsatzbereit)
|
||
* Liefert Objekte statt nur Beschriftungen, damit das Panel weiß, was es
|
||
* anlegen könnte: `{ setting, label, create }`.
|
||
*/
|
||
export function moduleMissing(mod) {
|
||
return (mod.requires ?? [])
|
||
// Effektiver Wert, nicht nur die Einstellung: was über die Umgebung
|
||
// gesetzt ist, fehlt nicht.
|
||
.filter((r) => !effectiveSetting(r.setting))
|
||
.map((r) => ({ setting: r.setting, label: r.label, create: r.create ?? null }));
|
||
}
|
||
|
||
/** Bauanleitung zu einer Einstellung — null, wenn sie sich nicht anlegen lässt */
|
||
export function creationPlan(setting) {
|
||
for (const mod of MODULES) {
|
||
for (const req of mod.requires ?? []) {
|
||
if (req.setting === setting && req.create) return { ...req.create, label: req.label };
|
||
}
|
||
}
|
||
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 */
|
||
export function moduleStates() {
|
||
return MODULES.map((mod) => ({
|
||
id: mod.id,
|
||
name: mod.name,
|
||
desc: mod.desc,
|
||
group: mod.group,
|
||
tab: mod.tab ?? null,
|
||
enabled: moduleEnabled(mod.id),
|
||
missing: moduleMissing(mod),
|
||
}));
|
||
}
|