diff --git a/README.md b/README.md index 946d684..10d335f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,11 @@ an, setzt bei privaten Kanälen die Rechte (nur Team sieht sie) und trägt die Einstellung gleich ein. Gibt es Kanal oder Rolle schon, werden sie verknüpft statt doppelt angelegt. +Daneben legt **„Alles auf einmal anlegen"** das ganze Grundgerüst an: erst eine +Vorschau (was ist neu, was ist schon da, in welche Kategorie kommt es), dann ein +Durchlauf mit Ergebnis je Zeile. Neue Kanäle landen in einer eigenen Kategorie, +und ein Fehler bei einem Kanal bricht die restlichen nicht ab. + Dafür braucht der Bot *Kanäle verwalten* bzw. *Rollen verwalten* — fehlt das Recht, sagt das Panel genau das. Die Vorlagen (Name, Thema, privat/öffentlich) stehen bei den Modulen in [`src/modules.js`](src/modules.js). Gelöscht oder diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index efa4545..a2eb56e 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -305,6 +305,10 @@ export default function Settings({ me }) { const [moduleGroups, setModuleGroups] = useState([]); const [modFilter, setModFilter] = useState('alle'); const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird + // Grundgerüst-Assistent + const [plan, setPlan] = useState(null); // Vorschau, null = zu + const [setupRunning, setSetupRunning] = useState(false); + const [setupResult, setSetupResult] = useState(null); // Text-Vorlagen (was der Bot nach außen schreibt) const [templates, setTemplates] = useState([]); const [templateGroups, setTemplateGroups] = useState([]); @@ -502,6 +506,35 @@ export default function Settings({ me }) { } } + /** Vorschau holen: was würde der Assistent anlegen? */ + async function loadPlan() { + try { + const res = await apiGet('/api/setup/plan'); + setPlan(res); + } catch (error) { + flash(`✗ ${error.body?.error ?? 'Vorschau fehlgeschlagen'}`); + } + } + + /** Grundgerüst anlegen — erst nach der Vorschau erreichbar */ + async function runSetup() { + setSetupRunning(true); + try { + const res = await apiPost('/api/setup/run', { kategorie: plan.kategorie }); + setModules(res.modules); + setForm(res.settings); + setData((d) => (d ? { ...d, settings: res.settings } : d)); + setSetupResult(res.ergebnisse); + setPlan(null); + const neu = res.ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length; + flash(`✓ ${neu} neu angelegt`); + } catch (error) { + flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`); + } finally { + setSetupRunning(false); + } + } + /** Fehlenden Kanal / fehlende Rolle vom Bot anlegen lassen */ async function createMissing(entry) { setCreating(entry.setting); @@ -1037,8 +1070,70 @@ export default function Settings({ me }) { <>

Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas — - bis dahin tun sie nichts. + bis dahin tun sie nichts. Einzeln anlegen, oder alles auf einmal.

+ + {/* Grundgerüst: erst Vorschau, dann anlegen */} + {!plan && ( +
+ +
+ )} + + {plan && ( +
+

+ {plan.schritte.filter((s) => !s.vorhanden).length} werden neu angelegt + {plan.schritte.some((s) => s.vorhanden) + && ` · ${plan.schritte.filter((s) => s.vorhanden).length} sind schon da und werden nur verknüpft`} + . Neue Kanäle landen in der Kategorie {plan.kategorie}. +

+
+ {plan.schritte.map((s) => ( +
+ + {s.art === 'rolle' ? '@' : '#'}{s.name} + + + {s.vorhanden ? 'vorhanden — wird verknüpft' : s.privat ? 'neu, privat' : 'neu'} + + {s.label} +
+ ))} +
+
+ + +
+
+ )} + + {setupResult && ( +
+

Ergebnis:

+
+ {setupResult.map((r) => ( +
+ {r.name ?? r.label} + + {r.ok + ? (r.wiederverwendet ? 'verknüpft' : 'angelegt') + : r.fehler} + +
+ ))} +
+
+ +
+
+ )}
{unfinished.map((m) => (
diff --git a/frontend/src/style.css b/frontend/src/style.css index 3b85e61..95838e1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -725,6 +725,41 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); } .btn-create { color: var(--neon); border-color: rgba(245, 197, 24, .45); } .btn-create:hover:not(:disabled) { background: rgba(245, 197, 24, .1); } .btn-create:disabled { opacity: .4; cursor: default; } + +/* Grundgerüst-Assistent: Vorschau und Ergebnis */ +.setup-plan { + border: 1px solid var(--border); border-radius: 12px; + background: rgba(255, 255, 255, .015); + padding: 1.1rem 1.2rem; margin-bottom: 1.2rem; + animation: riseIn .25s ease both; +} +.setup-plan-intro { color: var(--muted); font-weight: 300; line-height: 1.6; margin: 0 0 .9rem; } +.setup-plan-intro strong { color: var(--neon); font-weight: 600; } +.setup-plan-list { + display: flex; flex-direction: column; + border: 1px solid rgba(255, 255, 255, .06); border-radius: 8px; + margin-bottom: 1rem; max-height: 22rem; overflow-y: auto; +} +.setup-step { + display: flex; align-items: baseline; gap: 1rem; + padding: .45rem .8rem; + border-bottom: 1px solid rgba(255, 255, 255, .05); +} +.setup-step:last-child { border-bottom: 0; } +.setup-step-name { + font-family: var(--mono); font-size: .82rem; color: var(--neon); + min-width: 11rem; +} +/* Was es schon gibt, tritt zurück — es passiert ja nichts Neues */ +.setup-step.da .setup-step-name { color: var(--muted); } +.setup-step.fehler .setup-step-name { color: #f23f43; } +.setup-step-note { + font-family: var(--mono); font-size: .62rem; + letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); + flex: 1; +} +.setup-step.fehler .setup-step-note { color: #f23f43; text-transform: none; letter-spacing: .02em; } +.setup-step-for { color: var(--muted2); font-size: .82rem; font-weight: 300; } .settings-nav-item { display: flex; align-items: center; gap: .7rem; font-family: var(--mono); diff --git a/src/bot/server-setup.js b/src/bot/server-setup.js index 0139031..1de2dde 100644 --- a/src/bot/server-setup.js +++ b/src/bot/server-setup.js @@ -9,8 +9,13 @@ // dessen Verlauf. import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { setSetting } from '../db.js'; -import { creationPlan } from '../modules.js'; -import { discordGuildId } from '../runtime-settings.js'; +import { creationPlan, MODULES, moduleEnabled, moduleMissing } from '../modules.js'; +import { discordGuildId, brandName } from '../runtime-settings.js'; + +// Kurze Pause zwischen zwei Anlagen. Discord verträgt Bursts schlecht, und der +// Assistent legt schnell ein Dutzend Kanäle an. +const PAUSE_MS = 350; +const warte = (ms) => new Promise((r) => setTimeout(r, ms)); /** Server, auf dem gearbeitet wird — konfigurierter, sonst der erste */ function targetGuild(client) { @@ -23,10 +28,11 @@ function targetGuild(client) { * * @param {import('discord.js').Client} client * @param {string} setting — z. B. 'devlog_channel_id' + * @param {{ parent?: object|null }} [opts] — Kategorie für neue Kanäle * @returns {Promise<{ id: string, name: string, art: 'kanal'|'rolle' }>} * @throws {Error} mit einer Meldung, die im Panel angezeigt werden kann */ -export async function createForSetting(client, setting) { +export async function createForSetting(client, setting, { parent = null } = {}) { const plan = creationPlan(setting); if (!plan) throw new Error('Dafür gibt es keine Vorlage — bitte von Hand anlegen.'); @@ -80,6 +86,7 @@ export async function createForSetting(client, setting) { const channel = await guild.channels.create({ name: plan.name, type: gewuenschterTyp, + parent: parent ?? undefined, topic: plan.sprache ? undefined : plan.topic, permissionOverwrites: overwrites.length ? overwrites : undefined, reason: 'Über das d4rkbot-Panel angelegt', @@ -88,3 +95,96 @@ export async function createForSetting(client, setting) { setSetting(setting, channel.id); return { id: channel.id, name: channel.name, art: 'kanal', wiederverwendet: false }; } + +/* ── Grundgerüst-Assistent ─────────────────────────── */ + +/** + * Was der Assistent anlegen würde — für die Vorschau, bevor etwas passiert. + * + * Eine Einstellung kann von mehreren Modulen gebraucht werden (Devlogs und + * Wochen-Rückblick teilen sich den Devlog-Kanal), deshalb wird nach + * Einstellung entdoppelt. + */ +export function setupPlan(client) { + const guild = targetGuild(client); + const namen = new Set( + guild ? [...guild.channels.cache.values()].map((c) => c.name.toLowerCase()) : [] + ); + const rollennamen = new Set( + guild ? [...guild.roles.cache.values()].map((r) => r.name.toLowerCase()) : [] + ); + + const gesehen = new Set(); + const schritte = []; + for (const mod of MODULES) { + if (!moduleEnabled(mod.id)) continue; + for (const fehlt of moduleMissing(mod)) { + if (!fehlt.create || gesehen.has(fehlt.setting)) continue; + gesehen.add(fehlt.setting); + const rolle = Boolean(fehlt.create.rolle); + schritte.push({ + setting: fehlt.setting, + label: fehlt.label, + name: fehlt.create.name, + art: rolle ? 'rolle' : 'kanal', + privat: Boolean(fehlt.create.privat), + sprache: Boolean(fehlt.create.sprache), + // Gleichnamiges vorhanden? Dann wird verknüpft statt angelegt. + vorhanden: (rolle ? rollennamen : namen).has(fehlt.create.name.toLowerCase()), + fuer: mod.name, + }); + } + } + return schritte; +} + +/** + * Alles aus dem Plan anlegen. Neue Kanäle landen in einer eigenen Kategorie, + * damit der Server danach nicht wie ein Sammelsurium aussieht. + * + * Ein Fehler bei einem Schritt bricht nicht ab — die restlichen werden + * versucht, und am Ende steht pro Zeile, was passiert ist. + * + * @returns {Promise>} + */ +export async function runSetup(client, { kategorie } = {}) { + const schritte = setupPlan(client); + if (schritte.length === 0) return []; + + const guild = targetGuild(client); + const me = guild?.members.me ?? await guild?.members.fetchMe().catch(() => null); + if (!guild || !me) throw new Error('Kein Discord-Server gefunden.'); + if (!me.permissions.has(PermissionFlagsBits.ManageChannels)) { + throw new Error('Dem Bot fehlt das Recht „Kanäle verwalten".'); + } + + // Kategorie anlegen (oder eine gleichnamige wiederverwenden), aber nur wenn + // überhaupt Kanäle entstehen + const brauchtKategorie = schritte.some((s) => s.art === 'kanal' && !s.vorhanden); + const katName = (kategorie ?? brandName()).slice(0, 90); + let parent = null; + if (brauchtKategorie) { + parent = [...guild.channels.cache.values()].find( + (c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase() === katName.toLowerCase() + ) ?? await guild.channels.create({ + name: katName, + type: ChannelType.GuildCategory, + reason: 'Grundgerüst über das d4rkbot-Panel', + }).catch(() => null); + } + + const ergebnisse = []; + for (const [i, schritt] of schritte.entries()) { + if (i > 0) await warte(PAUSE_MS); + try { + const res = await createForSetting(client, schritt.setting, { parent }); + ergebnisse.push({ setting: schritt.setting, label: schritt.label, ok: true, ...res }); + } catch (error) { + ergebnisse.push({ + setting: schritt.setting, label: schritt.label, ok: false, + fehler: error.message || 'Fehlgeschlagen', + }); + } + } + return ergebnisse; +} diff --git a/src/web/api.js b/src/web/api.js index 0622a35..839f52d 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -226,6 +226,30 @@ export function registerApiRoutes(app, client) { } }); + // Grundgerüst: Vorschau, was der Assistent anlegen würde + app.get('/api/setup/plan', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const { setupPlan } = await import('../bot/server-setup.js'); + return { schritte: setupPlan(client), kategorie: brandName() }; + }); + + // … und ausführen + app.post('/api/setup/run', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const kategorie = String(request.body?.kategorie ?? '').trim() || undefined; + try { + const { runSetup } = await import('../bot/server-setup.js'); + const ergebnisse = await runSetup(client, { kategorie }); + const neu = ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length; + logAudit(getSessionUser(request), 'grundgerüst angelegt', `${neu} neu, ${ergebnisse.length} gesamt`); + request.log.info(`Grundgerüst: ${neu} neu von ${ergebnisse.length} Schritten`); + return { ok: true, ergebnisse, modules: moduleStates(), settings: currentSettings() }; + } catch (error) { + request.log.error({ err: error }, 'Grundgerüst fehlgeschlagen'); + return reply.code(400).send({ error: error.message || 'Fehlgeschlagen' }); + } + }); + // --- Stellwerte: Zahlen, die vorher fest im Code standen --- app.get('/api/tuning', async (request, reply) => {