From 15bcd53c99460272d56ac5585f9697c0d06f64c8 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Mon, 15 Jun 2026 23:58:24 +0200 Subject: [PATCH 01/10] ui: modernize WebUI and add managed themes --- package.json | 3 +- plugins/moderation/views/status.ejs | 18 +- scripts/verify-webui.js | 123 +++++ src/services/db.js | 9 + src/services/themes.js | 654 +++++++++++++++++++++++ src/web/public/app.js | 22 + src/web/public/lumi-components.css | 549 +++++++++++++++++++ src/web/public/lumi-layout.css | 260 +++++++++ src/web/public/lumi-tokens.css | 71 +++ src/web/public/theme-editor.css | 381 +++++++++++++ src/web/public/theme-editor.js | 89 +++ src/web/server.js | 218 +++++--- src/web/views/admin-dashboard.ejs | 30 +- src/web/views/admin-pages.ejs | 10 +- src/web/views/admin-plugins.ejs | 10 +- src/web/views/admin-settings.ejs | 10 +- src/web/views/admin-theme.ejs | 376 +++++++++---- src/web/views/admin-updates.ejs | 31 +- src/web/views/admin-users.ejs | 10 +- src/web/views/leaderboards.ejs | 10 +- src/web/views/partials/layout-bottom.ejs | 1 + src/web/views/partials/layout-top.ejs | 43 +- src/web/views/partials/page-header.ejs | 11 + src/web/views/partials/theme-vars.ejs | 91 ++++ src/web/views/setup.ejs | 7 +- 25 files changed, 2766 insertions(+), 271 deletions(-) create mode 100644 scripts/verify-webui.js create mode 100644 src/services/themes.js create mode 100644 src/web/public/lumi-components.css create mode 100644 src/web/public/lumi-layout.css create mode 100644 src/web/public/lumi-tokens.css create mode 100644 src/web/public/theme-editor.css create mode 100644 src/web/public/theme-editor.js create mode 100644 src/web/views/partials/page-header.ejs create mode 100644 src/web/views/partials/theme-vars.ejs diff --git a/package.json b/package.json index 7b93c08..9d1784a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "commonjs", "scripts": { "start": "node src/main.js", - "run": "node run.js" + "run": "node run.js", + "verify:webui": "node scripts/verify-webui.js" }, "engines": { "node": ">=18" diff --git a/plugins/moderation/views/status.ejs b/plugins/moderation/views/status.ejs index 7fcefc8..f0687da 100644 --- a/plugins/moderation/views/status.ejs +++ b/plugins/moderation/views/status.ejs @@ -4,11 +4,15 @@ <%= title %> - + + + + + <%- include("../../../src/web/views/partials/theme-vars", { theme }) %> - -
-
+ +
+

Access restricted

Your account is currently restricted by moderation.

@@ -29,7 +33,7 @@ <%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %>
-
+

Summary

<%= sanction.reason_short %>

Details

@@ -37,6 +41,6 @@

Moderator: <%= sanction.created_by_name || 'Staff' %>

-
+ - + diff --git a/scripts/verify-webui.js b/scripts/verify-webui.js new file mode 100644 index 0000000..cd7e9ad --- /dev/null +++ b/scripts/verify-webui.js @@ -0,0 +1,123 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const ejs = require("ejs"); + +const root = path.join(__dirname, ".."); + +function listFiles(directory, extension, output = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) listFiles(target, extension, output); + else if (target.endsWith(extension)) output.push(target); + } + return output; +} + +function verifyViews() { + const viewRoots = [path.join(root, "src", "web", "views"), path.join(root, "plugins")]; + const files = viewRoots.flatMap((directory) => listFiles(directory, ".ejs")); + for (const file of files) { + ejs.compile(fs.readFileSync(file, "utf8"), { filename: file }); + } + return files.length; +} + +function verifyThemeService() { + const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-theme-test-")); + const serviceDir = path.join(sandbox, "src", "services"); + let database = null; + fs.mkdirSync(serviceDir, { recursive: true }); + for (const file of ["config.js", "db.js", "settings.js", "themes.js"]) { + fs.copyFileSync( + path.join(root, "src", "services", file), + path.join(serviceDir, file) + ); + } + + try { + database = require(path.join(serviceDir, "db.js")); + database.migrate(); + require(path.join(serviceDir, "settings.js")).ensureDefaults(); + const themes = require(path.join(serviceDir, "themes.js")); + + assert.strictEqual(themes.BUILTIN_THEMES.length, 6); + for (const theme of themes.BUILTIN_THEMES) { + assert.deepStrictEqual(themes.validateThemeValues(theme), []); + } + + const library = themes.listThemes(); + assert(library.every((theme) => theme.builtin)); + assert.throws( + () => themes.saveCustomTheme("builtin:lumi-default", library[0]), + /read-only/ + ); + + const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme"); + assert.strictEqual(copy.builtin, false); + themes.setActiveTheme(copy.id); + assert.strictEqual(themes.getActiveTheme().id, copy.id); + + const invalid = JSON.parse(JSON.stringify(copy)); + invalid.light.text = "not-a-color"; + assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex color/); + + const renamed = themes.renameCustomTheme(copy.id, "Verified Theme"); + assert.strictEqual(renamed.name, "Verified Theme"); + + const repaired = themes.normalizeThemeValues({ + ...renamed, + light: { ...renamed.light, text: "#ffffff", surface: "#ffffff" } + }); + assert.notStrictEqual(repaired.light.text, repaired.light.surface); + + const themeView = path.join(root, "src", "web", "views", "admin-theme.ejs"); + const rendered = ejs.render(fs.readFileSync(themeView, "utf8"), { + title: "Theming", + siteTitle: "Lumi Bot", + assetVersion: "verify", + theme: renamed, + activeTheme: renamed, + themes: themes.listThemes(), + editingTheme: renamed, + botAvatar: null, + navSections: [], + user: { username: "Admin" }, + userAvatar: null, + userInitial: "A", + platformLogins: [], + flash: null, + softError: null + }, { filename: themeView }); + assert(rendered.includes("data-theme-editor")); + assert(rendered.includes("Built-in · read-only")); + + const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs"); + const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), { + title: "Access restricted", + assetVersion: "verify", + theme: renamed, + sanction: { + action_type: "timeout", + status: "active", + created_at: Date.now(), + expires_at: Date.now() + 60000, + reason_short: "Verification", + reason_detail: "Standalone themed view verification.", + created_by_name: "Admin" + } + }, { filename: statusView }); + assert(statusRendered.includes("/lumi-components.css")); + assert(statusRendered.includes('data-theme-id="custom:')); + + themes.deleteCustomTheme(copy.id); + assert.strictEqual(themes.getActiveTheme().id, themes.DEFAULT_THEME_ID); + } finally { + database?.db.close(); + fs.rmSync(sandbox, { recursive: true, force: true }); + } +} + +const viewCount = verifyViews(); +verifyThemeService(); +console.log(`WebUI verification passed: ${viewCount} EJS views and theme CRUD.`); diff --git a/src/services/db.js b/src/services/db.js index 70f21f7..8de5e1b 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -18,6 +18,15 @@ function migrate() { updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS custom_themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme_id TEXT NOT NULL, + values_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/services/themes.js b/src/services/themes.js new file mode 100644 index 0000000..bb9db6e --- /dev/null +++ b/src/services/themes.js @@ -0,0 +1,654 @@ +const crypto = require("crypto"); +const { db } = require("./db"); +const { getSetting, setSetting } = require("./settings"); + +const DEFAULT_THEME_ID = "builtin:lumi-default"; +const THEME_SYSTEM_VERSION = 1; +const COLOR_PATTERN = /^#[0-9a-f]{6}$/i; + +const MODE_COLOR_FIELDS = [ + "bg1", + "bg2", + "bg3", + "text", + "muted", + "accent", + "accentAlt", + "success", + "warning", + "danger", + "info", + "surface", + "surface2", + "surface3", + "border", + "link", + "buttonBg", + "buttonText", + "buttonHover", + "inputBg", + "inputBorder", + "inputText", + "focusRing" +]; + +const ROLE_COLOR_FIELDS = ["public", "mod", "admin"]; + +const DEFAULT_MODE_LIGHT = { + bg1: "#dff4f2", + bg2: "#f5f7f8", + bg3: "#fff1dc", + text: "#182026", + muted: "#5a6872", + accent: "#176b75", + accentAlt: "#e58b2b", + success: "#23845b", + warning: "#a96612", + danger: "#bd4d4d", + info: "#3479a8", + surface: "#ffffff", + surface2: "#f4f7f8", + surface3: "#edf2f3", + border: "#d8e0e3", + link: "#0d6470", + buttonBg: "#176b75", + buttonText: "#ffffff", + buttonHover: "#0f5660", + inputBg: "#ffffff", + inputBorder: "#c8d3d7", + inputText: "#182026", + focusRing: "#2f98a5" +}; + +const DEFAULT_MODE_DARK = { + bg1: "#102c31", + bg2: "#11171b", + bg3: "#261e18", + text: "#f2f6f7", + muted: "#aebbc1", + accent: "#63c4cf", + accentAlt: "#f0b45f", + success: "#59c894", + warning: "#e4b35d", + danger: "#ef7b78", + info: "#74b9e6", + surface: "#1a2227", + surface2: "#202b31", + surface3: "#27343b", + border: "#35434a", + link: "#7bd3dc", + buttonBg: "#4dafba", + buttonText: "#08191c", + buttonHover: "#68c8d2", + inputBg: "#151d21", + inputBorder: "#42535b", + inputText: "#f2f6f7", + focusRing: "#7bd3dc" +}; + +const DEFAULT_THEME_VALUES = { + light: DEFAULT_MODE_LIGHT, + dark: DEFAULT_MODE_DARK, + role: { + public: "#ffffff", + mod: "#23845b", + admin: "#bd4d6d" + }, + metrics: { + radius: 14, + shadowStrength: 0.14, + spacingScale: 1 + } +}; + +function mergeMode(base, override = {}) { + return Object.fromEntries( + MODE_COLOR_FIELDS.map((field) => [field, override[field] || base[field]]) + ); +} + +function createBuiltin(id, name, description, overrides = {}) { + return Object.freeze({ + id: `builtin:${id}`, + name, + description, + builtin: true, + readOnly: true, + baseThemeId: null, + light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light), + dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark), + role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) }, + metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) } + }); +} + +const BUILTIN_THEMES = [ + createBuiltin( + "lumi-default", + "Lumi Default", + "Balanced teal and warm accents with automatic light and dark modes." + ), + createBuiltin("lumi-dark", "Lumi Dark", "A deep, low-glare theme for dark workspaces.", { + light: { + bg1: "#18242a", + bg2: "#11171b", + bg3: "#241d19", + text: "#f3f6f7", + muted: "#b4c0c5", + surface: "#1c252a", + surface2: "#222d33", + surface3: "#29363d", + border: "#3b4a52", + inputBg: "#141c20", + inputBorder: "#465860", + inputText: "#f3f6f7", + accent: "#67c6d0", + link: "#7bd3dc", + buttonBg: "#51b4bf", + buttonText: "#08191c", + buttonHover: "#71d0da", + focusRing: "#7bd3dc" + } + }), + createBuiltin("lumi-light", "Lumi Light", "A crisp, bright theme with restrained shadows.", { + dark: DEFAULT_MODE_LIGHT, + metrics: { shadowStrength: 0.08 } + }), + createBuiltin("high-contrast", "High Contrast", "Maximum clarity with strong focus and status colors.", { + light: { + bg1: "#ffffff", + bg2: "#ffffff", + bg3: "#f2f2f2", + text: "#000000", + muted: "#303030", + accent: "#004f5a", + accentAlt: "#8a4300", + success: "#006b3c", + warning: "#7a4700", + danger: "#a00000", + info: "#004b88", + surface: "#ffffff", + surface2: "#f5f5f5", + surface3: "#e8e8e8", + border: "#555555", + link: "#003f99", + buttonBg: "#003f49", + buttonText: "#ffffff", + buttonHover: "#002c33", + inputBg: "#ffffff", + inputBorder: "#333333", + inputText: "#000000", + focusRing: "#005fcc" + }, + dark: { + bg1: "#000000", + bg2: "#000000", + bg3: "#101010", + text: "#ffffff", + muted: "#d6d6d6", + accent: "#67e8f9", + accentAlt: "#ffd166", + success: "#65e6a3", + warning: "#ffd166", + danger: "#ff8c8c", + info: "#8fd3ff", + surface: "#080808", + surface2: "#151515", + surface3: "#222222", + border: "#aaaaaa", + link: "#8fd3ff", + buttonBg: "#a5f3fc", + buttonText: "#000000", + buttonHover: "#ffffff", + inputBg: "#000000", + inputBorder: "#dddddd", + inputText: "#ffffff", + focusRing: "#ffffff" + }, + metrics: { radius: 8, shadowStrength: 0, spacingScale: 1.05 } + }), + createBuiltin("midnight", "Midnight", "Cool blue surfaces with violet highlights.", { + light: { + bg1: "#dce8ff", + bg2: "#f4f6fb", + bg3: "#eee8ff", + accent: "#4457a6", + accentAlt: "#8258b7", + link: "#354a9b", + buttonBg: "#4457a6", + buttonHover: "#34448a", + focusRing: "#6f82d8" + }, + dark: { + bg1: "#10182f", + bg2: "#0b1020", + bg3: "#211630", + surface: "#141c32", + surface2: "#19233d", + surface3: "#202c49", + border: "#334160", + accent: "#91a4ff", + accentAlt: "#c49aff", + link: "#aab8ff", + buttonBg: "#91a4ff", + buttonText: "#0b1020", + buttonHover: "#b0bcff", + focusRing: "#c49aff" + } + }), + createBuiltin("soft-aurora", "Soft Aurora", "A gentle mint, lavender, and coral palette.", { + light: { + bg1: "#dcf8ee", + bg2: "#f8f5fb", + bg3: "#ffe9e4", + accent: "#397f70", + accentAlt: "#986aa8", + link: "#306f63", + buttonBg: "#397f70", + buttonHover: "#2d665a", + focusRing: "#8a6fa8" + }, + dark: { + bg1: "#17352f", + bg2: "#171a22", + bg3: "#38242e", + surface: "#20262d", + surface2: "#283038", + surface3: "#313b44", + border: "#43505a", + accent: "#82d7c1", + accentAlt: "#d4a7e1", + link: "#9ce7d4", + buttonBg: "#72c9b3", + buttonText: "#10231f", + buttonHover: "#96e3d0", + focusRing: "#d4a7e1" + }, + metrics: { radius: 18, shadowStrength: 0.1, spacingScale: 1.05 } + }) +]; + +const BUILTIN_MAP = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme])); + +function cloneTheme(theme) { + return JSON.parse(JSON.stringify(theme)); +} + +function getBuiltinTheme(id = DEFAULT_THEME_ID) { + return BUILTIN_MAP.get(id) || BUILTIN_MAP.get(DEFAULT_THEME_ID); +} + +function customKey(id) { + return `custom:${id}`; +} + +function customId(themeId) { + return String(themeId || "").startsWith("custom:") + ? String(themeId).slice("custom:".length) + : null; +} + +function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) { + const source = values && typeof values === "object" ? values : {}; + const normalized = { + light: mergeMode(baseTheme.light, source.light), + dark: mergeMode(baseTheme.dark, source.dark), + role: { ...baseTheme.role, ...(source.role || {}) }, + metrics: { ...baseTheme.metrics, ...(source.metrics || {}) } + }; + + for (const mode of ["light", "dark"]) { + for (const field of MODE_COLOR_FIELDS) { + if (!COLOR_PATTERN.test(normalized[mode][field])) { + normalized[mode][field] = baseTheme[mode][field]; + } + } + } + for (const field of ROLE_COLOR_FIELDS) { + if (!COLOR_PATTERN.test(normalized.role[field])) { + normalized.role[field] = baseTheme.role[field]; + } + } + normalized.metrics.radius = clampNumber( + normalized.metrics.radius, + 0, + 32, + baseTheme.metrics.radius + ); + normalized.metrics.shadowStrength = clampNumber( + normalized.metrics.shadowStrength, + 0, + 0.35, + baseTheme.metrics.shadowStrength + ); + normalized.metrics.spacingScale = clampNumber( + normalized.metrics.spacingScale, + 0.75, + 1.35, + baseTheme.metrics.spacingScale + ); + for (const mode of ["light", "dark"]) { + if (contrastRatio(normalized[mode].text, normalized[mode].surface) < 4.5) { + normalized[mode].text = baseTheme[mode].text; + normalized[mode].surface = baseTheme[mode].surface; + } + if (contrastRatio(normalized[mode].buttonText, normalized[mode].buttonBg) < 4.5) { + normalized[mode].buttonText = baseTheme[mode].buttonText; + normalized[mode].buttonBg = baseTheme[mode].buttonBg; + } + if (contrastRatio(normalized[mode].inputText, normalized[mode].inputBg) < 4.5) { + normalized[mode].inputText = baseTheme[mode].inputText; + normalized[mode].inputBg = baseTheme[mode].inputBg; + } + } + return normalized; +} + +function clampNumber(value, min, max, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function parseHex(value) { + const raw = String(value || "").slice(1); + return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16)); +} + +function relativeLuminance(value) { + const channels = parseHex(value).map((channel) => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrastRatio(left, right) { + const a = relativeLuminance(left); + const b = relativeLuminance(right); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +function validateThemeValues(values) { + const errors = []; + for (const mode of ["light", "dark"]) { + for (const field of MODE_COLOR_FIELDS) { + if (!COLOR_PATTERN.test(String(values?.[mode]?.[field] || ""))) { + errors.push(`${mode}.${field} must be a six-digit hex color.`); + } + } + } + for (const field of ROLE_COLOR_FIELDS) { + if (!COLOR_PATTERN.test(String(values?.role?.[field] || ""))) { + errors.push(`role.${field} must be a six-digit hex color.`); + } + } + + const metricRules = [ + ["radius", 0, 32], + ["shadowStrength", 0, 0.35], + ["spacingScale", 0.75, 1.35] + ]; + for (const [field, min, max] of metricRules) { + const value = Number(values?.metrics?.[field]); + if (!Number.isFinite(value) || value < min || value > max) { + errors.push(`metrics.${field} must be between ${min} and ${max}.`); + } + } + + if (!errors.length) { + for (const mode of ["light", "dark"]) { + if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) { + errors.push(`${mode} text and surface colors need at least 4.5:1 contrast.`); + } + if (contrastRatio(values[mode].buttonText, values[mode].buttonBg) < 4.5) { + errors.push(`${mode} button text and background need at least 4.5:1 contrast.`); + } + if (contrastRatio(values[mode].inputText, values[mode].inputBg) < 4.5) { + errors.push(`${mode} input text and background need at least 4.5:1 contrast.`); + } + } + } + return errors; +} + +function legacyThemeValues() { + const legacy = { + light: { + bg1: getSetting("theme_light_bg_1", "#ffe5c4"), + bg2: getSetting("theme_light_bg_2", "#f4efe8"), + bg3: getSetting("theme_light_bg_3", "#e9f3f1"), + text: getSetting("theme_light_text", "#121518"), + muted: getSetting("theme_light_text_muted", "#2c3137"), + accent: getSetting("theme_light_accent", "#0f6a78"), + accentAlt: getSetting("theme_light_accent_alt", "#f4a340"), + danger: getSetting("theme_light_danger", "#d66d5c"), + surface: getSetting("theme_light_surface", "#ffffff"), + surface2: getSetting("theme_light_surface_2", "#fbf9f6"), + surface3: getSetting("theme_light_surface_3", "#f9f5ef"), + border: getSetting("theme_light_border", "#e3ddd6") + }, + dark: { + bg1: getSetting("theme_dark_bg_1", "#1b1d1f"), + bg2: getSetting("theme_dark_bg_2", "#16181b"), + bg3: getSetting("theme_dark_bg_3", "#0f1113"), + text: getSetting("theme_dark_text", "#f2f0ec"), + muted: getSetting("theme_dark_text_muted", "#c5bfb7"), + accent: getSetting("theme_dark_accent", "#4fb6c2"), + accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"), + danger: getSetting("theme_dark_danger", "#e08173"), + surface: getSetting("theme_dark_surface", "#232629"), + surface2: getSetting("theme_dark_surface_2", "#2b2f33"), + surface3: getSetting("theme_dark_surface_3", "#30353a"), + border: getSetting("theme_dark_border", "#34393d") + }, + role: { + public: getSetting("theme_role_public", "#ffffff"), + mod: getSetting("theme_role_mod", "#2cb678"), + admin: getSetting("theme_role_admin", "#e35678") + } + }; + return normalizeThemeValues(legacy, getBuiltinTheme()); +} + +function legacyWasCustomized(values) { + const defaults = { + light: { + bg1: "#ffe5c4", bg2: "#f4efe8", bg3: "#e9f3f1", text: "#121518", + muted: "#2c3137", accent: "#0f6a78", accentAlt: "#f4a340", + danger: "#d66d5c", surface: "#ffffff", surface2: "#fbf9f6", + surface3: "#f9f5ef", border: "#e3ddd6" + }, + dark: { + bg1: "#1b1d1f", bg2: "#16181b", bg3: "#0f1113", text: "#f2f0ec", + muted: "#c5bfb7", accent: "#4fb6c2", accentAlt: "#f1b765", + danger: "#e08173", surface: "#232629", surface2: "#2b2f33", + surface3: "#30353a", border: "#34393d" + }, + role: { public: "#ffffff", mod: "#2cb678", admin: "#e35678" } + }; + return ["light", "dark", "role"].some((group) => + Object.entries(defaults[group]).some(([key, value]) => values[group][key] !== value) + ); +} + +function ensureThemeMigration() { + if (Number(getSetting("theme_system_version", 0)) >= THEME_SYSTEM_VERSION) return; + const legacy = legacyThemeValues(); + setSetting("theme_system_version", THEME_SYSTEM_VERSION); + if (legacyWasCustomized(legacy)) { + const theme = insertCustomTheme("Migrated Theme", DEFAULT_THEME_ID, legacy); + setSetting("theme_active_id", theme.id); + } else { + setSetting("theme_active_id", DEFAULT_THEME_ID); + } +} + +function rowToTheme(row) { + const base = getBuiltinTheme(row.base_theme_id); + let stored = {}; + try { + stored = JSON.parse(row.values_json); + } catch { + stored = {}; + } + const values = normalizeThemeValues(stored, base); + return { + id: customKey(row.id), + name: row.name, + description: `Custom theme based on ${base.name}.`, + builtin: false, + readOnly: false, + baseThemeId: base.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + ...values + }; +} + +function getThemeById(themeId) { + ensureThemeMigration(); + if (BUILTIN_MAP.has(themeId)) return cloneTheme(BUILTIN_MAP.get(themeId)); + const id = customId(themeId); + if (!id) return null; + const row = db.prepare("SELECT * FROM custom_themes WHERE id = ?").get(id); + return row ? rowToTheme(row) : null; +} + +function listThemes() { + ensureThemeMigration(); + const custom = db + .prepare("SELECT * FROM custom_themes ORDER BY lower(name), created_at") + .all() + .map(rowToTheme); + return [...BUILTIN_THEMES.map(cloneTheme), ...custom]; +} + +function getActiveTheme() { + ensureThemeMigration(); + const requested = getSetting("theme_active_id", DEFAULT_THEME_ID); + const theme = getThemeById(requested) || cloneTheme(getBuiltinTheme()); + if (theme.id !== requested) setSetting("theme_active_id", theme.id); + return theme; +} + +function setActiveTheme(themeId) { + const theme = getThemeById(themeId); + if (!theme) throw new Error("Theme not found."); + setSetting("theme_active_id", theme.id); + return theme; +} + +function cleanName(value) { + const name = String(value || "").trim().replace(/\s+/g, " "); + if (name.length < 2 || name.length > 60) { + throw new Error("Theme name must be between 2 and 60 characters."); + } + return name; +} + +function insertCustomTheme(name, baseThemeId, values) { + const clean = cleanName(name); + const base = getBuiltinTheme(baseThemeId); + const normalized = normalizeThemeValues(values, base); + const errors = validateThemeValues(normalized); + if (errors.length) throw new Error(errors[0]); + const id = crypto.randomUUID(); + const now = Date.now(); + db.prepare( + "INSERT INTO custom_themes (id, name, base_theme_id, values_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run(id, clean, base.id, JSON.stringify(normalized), now, now); + return getThemeById(customKey(id)); +} + +function duplicateTheme(themeId, name) { + const source = getThemeById(themeId); + if (!source) throw new Error("Theme not found."); + return insertCustomTheme( + name || `${source.name} Copy`, + source.builtin ? source.id : source.baseThemeId, + source + ); +} + +function saveCustomTheme(themeId, values) { + const id = customId(themeId); + if (!id) throw new Error("Built-in themes are read-only."); + const current = getThemeById(themeId); + if (!current) throw new Error("Theme not found."); + const normalized = normalizeThemeValues(values, getBuiltinTheme(current.baseThemeId)); + const errors = validateThemeValues(values); + if (errors.length) { + const error = new Error(errors[0]); + error.validationErrors = errors; + throw error; + } + db.prepare( + "UPDATE custom_themes SET values_json = ?, updated_at = ? WHERE id = ?" + ).run(JSON.stringify(normalized), Date.now(), id); + return getThemeById(themeId); +} + +function renameCustomTheme(themeId, name) { + const id = customId(themeId); + if (!id) throw new Error("Built-in themes cannot be renamed."); + const result = db + .prepare("UPDATE custom_themes SET name = ?, updated_at = ? WHERE id = ?") + .run(cleanName(name), Date.now(), id); + if (!result.changes) throw new Error("Theme not found."); + return getThemeById(themeId); +} + +function deleteCustomTheme(themeId) { + const id = customId(themeId); + if (!id) throw new Error("Built-in themes cannot be deleted."); + const activeId = getSetting("theme_active_id", DEFAULT_THEME_ID); + const result = db.prepare("DELETE FROM custom_themes WHERE id = ?").run(id); + if (!result.changes) throw new Error("Theme not found."); + if (activeId === themeId) setSetting("theme_active_id", DEFAULT_THEME_ID); +} + +function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) { + const values = { light: {}, dark: {}, role: {}, metrics: {} }; + for (const mode of ["light", "dark"]) { + for (const field of MODE_COLOR_FIELDS) { + values[mode][field] = String( + body?.[`${mode}_${field}`] ?? fallbackTheme[mode][field] + ).trim(); + } + } + for (const field of ROLE_COLOR_FIELDS) { + values.role[field] = String( + body?.[`role_${field}`] ?? fallbackTheme.role[field] + ).trim(); + } + values.metrics.radius = Number(body?.metrics_radius ?? fallbackTheme.metrics.radius); + values.metrics.shadowStrength = Number( + body?.metrics_shadowStrength ?? fallbackTheme.metrics.shadowStrength + ); + values.metrics.spacingScale = Number( + body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale + ); + return values; +} + +module.exports = { + BUILTIN_THEMES, + DEFAULT_THEME_ID, + MODE_COLOR_FIELDS, + ROLE_COLOR_FIELDS, + contrastRatio, + deleteCustomTheme, + duplicateTheme, + getActiveTheme, + getThemeById, + listThemes, + normalizeThemeValues, + renameCustomTheme, + saveCustomTheme, + setActiveTheme, + validateThemeValues, + valuesFromRequest +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index 04aad82..cad0e8d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1,6 +1,11 @@ (() => { const body = document.body; const media = window.matchMedia("(max-width: 900px)"); + const sidebarPreferenceKey = "lumi-sidebar-collapsed"; + + if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") { + body.classList.add("sidebar-collapsed"); + } document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => { button.addEventListener("click", () => { @@ -8,10 +13,18 @@ body.classList.toggle("sidebar-open"); } else { body.classList.toggle("sidebar-collapsed"); + window.localStorage.setItem( + sidebarPreferenceKey, + body.classList.contains("sidebar-collapsed") ? "true" : "false" + ); } }); }); + document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => { + body.classList.remove("sidebar-open"); + }); + document.querySelectorAll(".nav-link").forEach((link) => { link.addEventListener("click", () => { if (body.classList.contains("sidebar-open")) { @@ -20,6 +33,15 @@ }); }); + media.addEventListener?.("change", () => { + body.classList.remove("sidebar-open"); + if (media.matches) { + body.classList.remove("sidebar-collapsed"); + } else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") { + body.classList.add("sidebar-collapsed"); + } + }); + const editToggles = Array.from( document.querySelectorAll("[data-edit-toggle]") ); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css new file mode 100644 index 0000000..f456769 --- /dev/null +++ b/src/web/public/lumi-components.css @@ -0,0 +1,549 @@ +::selection { + color: var(--lumi-button-text); + background: var(--lumi-primary); +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--lumi-focus) 78%, transparent); + outline-offset: 3px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + color: var(--lumi-text); + font-family: var(--lumi-font-display); + line-height: 1.18; + text-wrap: balance; +} + +h1 { + font-size: clamp(1.75rem, 4vw, 2.55rem); + letter-spacing: -0.035em; +} + +h2 { + font-size: clamp(1.25rem, 2.5vw, 1.6rem); + letter-spacing: -0.02em; +} + +p { + max-width: 75ch; +} + +.eyebrow { + display: inline-block; + margin-bottom: var(--lumi-space-1); + color: var(--lumi-primary); + font: 700 0.75rem/1 var(--lumi-font-display); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +a { + color: var(--lumi-link); + text-underline-offset: 0.18em; +} + +code, +pre { + font-family: var(--lumi-font-mono); +} + +.hero { + position: relative; + overflow: hidden; + padding: clamp(1.5rem, 5vw, 3.5rem); + border: 1px solid color-mix(in srgb, var(--lumi-primary) 25%, var(--lumi-border)); + border-radius: var(--lumi-radius-lg); + background: + linear-gradient(125deg, color-mix(in srgb, var(--lumi-primary) 18%, transparent), transparent 56%), + linear-gradient(310deg, color-mix(in srgb, var(--lumi-accent) 17%, transparent), transparent 50%), + var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); + animation: none; +} + +.hero::after { + content: ""; + position: absolute; + width: 14rem; + height: 14rem; + right: -6rem; + top: -7rem; + border: 2.5rem solid color-mix(in srgb, var(--lumi-primary) 10%, transparent); + border-radius: 50%; + pointer-events: none; +} + +.hero h1 { + max-width: 18ch; + margin-bottom: var(--lumi-space-3); +} + +.hero > * { + position: relative; + z-index: 1; +} + +.card, +.panel, +.lumi-panel { + padding: clamp(1rem, 2vw, 1.5rem); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: color-mix(in srgb, var(--lumi-surface) 97%, transparent); + box-shadow: var(--lumi-shadow-sm); + animation: none; +} + +.grid .card { + height: 100%; +} + +.card .card { + background: var(--lumi-surface-subtle); + box-shadow: none; +} + +.card > :last-child, +.panel > :last-child { + margin-bottom: 0; +} + +section.card:has(> table.table) { + overflow-x: auto; +} + +.button, +button.button, +input[type="submit"].button { + min-height: var(--lumi-control-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--lumi-space-2); + padding: 0.65rem 1rem; + border: 1px solid transparent; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-button-bg); + color: var(--lumi-button-text); + box-shadow: var(--lumi-shadow-sm); + font: 700 0.925rem/1 var(--lumi-font-body); + text-align: center; + text-decoration: none; + transition: + transform var(--lumi-transition), + background var(--lumi-transition), + border-color var(--lumi-transition), + box-shadow var(--lumi-transition); +} + +.button:hover:not(:disabled):not(.disabled) { + background: var(--lumi-button-hover); + box-shadow: var(--lumi-shadow-md); + transform: translateY(-1px); +} + +.button:active:not(:disabled):not(.disabled) { + transform: translateY(0); +} + +.button.subtle, +.button.secondary { + border-color: var(--lumi-border); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); + box-shadow: none; +} + +.button.subtle:hover:not(:disabled):not(.disabled), +.button.secondary:hover:not(:disabled):not(.disabled) { + border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border)); + background: var(--lumi-surface-raised); +} + +.button.danger { + background: var(--lumi-danger); + color: #ffffff; +} + +.button.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--lumi-danger) 84%, black); +} + +.button:disabled, +.button.disabled, +button:disabled { + cursor: not-allowed; + opacity: 0.55; + box-shadow: none; + transform: none; +} + +.icon-button { + width: var(--lumi-control-height); + height: var(--lumi-control-height); + min-width: var(--lumi-control-height); + border-color: var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); + transition: background var(--lumi-transition), transform var(--lumi-transition); +} + +.icon-button:hover { + background: var(--lumi-surface-raised); + transform: translateY(-1px); +} + +.link { + color: var(--lumi-link); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--lumi-link) 35%, transparent); + text-underline-offset: 0.22em; +} + +.link:hover { + text-decoration-color: currentColor; +} + +.form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--lumi-space-4); +} + +.form-grid .field { + min-width: 0; + gap: var(--lumi-space-2); +} + +.field > label:first-child, +fieldset > legend { + color: var(--lumi-text); + font-weight: 700; +} + +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]), +select, +textarea, +.table-search { + width: 100%; + min-height: var(--lumi-control-height); + padding: 0.65rem 0.8rem; + border: 1px solid var(--lumi-input-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-input-bg); + color: var(--lumi-input-text); + font: inherit; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.035); + transition: border-color var(--lumi-transition), box-shadow var(--lumi-transition); +} + +textarea { + min-height: 7rem; + resize: vertical; +} + +input:hover, +select:hover, +textarea:hover { + border-color: color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-input-border)); +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--lumi-focus); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-focus) 22%, transparent); +} + +input[type="checkbox"], +input[type="radio"] { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--lumi-primary); +} + +input[type="color"] { + min-width: 3.5rem; + box-shadow: var(--lumi-shadow-sm); +} + +.hint, +.command-subtitle, +.table-note, +.table-page-label { + color: var(--lumi-text-muted); +} + +.table-wrap { + width: 100%; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + overflow: auto; +} + +.table { + min-width: 42rem; +} + +.table th, +.table td { + padding: 0.8rem 0.9rem; +} + +.table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--lumi-surface-subtle); + color: var(--lumi-text-muted); + font: 700 0.75rem/1.3 var(--lumi-font-display); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.table tbody tr { + transition: background var(--lumi-transition); +} + +.table tbody tr:hover { + background: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface)); +} + +.table tbody tr:last-child td { + border-bottom: 0; +} + +.badge, +.pill, +.status-indicator, +.level-pill, +.origin-pill { + display: inline-flex; + align-items: center; + min-height: 1.65rem; + border-radius: var(--lumi-radius-pill); +} + +.status-indicator::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + margin-right: var(--lumi-space-2); + border-radius: 50%; + background: currentColor; +} + +.status-success { + color: var(--lumi-success); +} + +.status-warning { + color: var(--lumi-warning); +} + +.status-danger { + color: var(--lumi-danger); +} + +.flash, +.alert, +.callout { + padding: var(--lumi-space-3) var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-left-width: 4px; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); +} + +.flash.success, +.alert.success, +.callout.success { + border-color: color-mix(in srgb, var(--lumi-success) 45%, var(--lumi-border)); + border-left-color: var(--lumi-success); + background: color-mix(in srgb, var(--lumi-success) 10%, var(--lumi-surface)); + color: var(--lumi-text); +} + +.flash.error, +.alert.danger, +.callout.danger { + border-color: color-mix(in srgb, var(--lumi-danger) 45%, var(--lumi-border)); + border-left-color: var(--lumi-danger); + background: color-mix(in srgb, var(--lumi-danger) 10%, var(--lumi-surface)); + color: var(--lumi-text); +} + +.flash.info, +.alert.info { + border-left-color: var(--lumi-info); + background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface)); +} + +.list li { + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); +} + +.modal-backdrop { + padding: var(--lumi-space-4); + background: rgba(5, 10, 12, 0.62); + backdrop-filter: blur(5px); +} + +.modal { + width: min(42rem, 100%); + max-height: min(48rem, calc(100vh - 2rem)); + overflow-y: auto; + padding: var(--lumi-space-5); + border-radius: var(--lumi-radius-lg); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-lg); +} + +.modal-header h2, +.modal-header h3 { + margin-bottom: 0; +} + +.tabs, +.ai-tabs { + display: flex; + gap: var(--lumi-space-1); + padding: var(--lumi-space-1); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + overflow-x: auto; +} + +.tabs a, +.ai-tabs a { + min-height: 2.4rem; + display: inline-flex; + align-items: center; + padding: 0.45rem 0.8rem; + border-radius: var(--lumi-radius-sm); + color: var(--lumi-text-muted); + font-weight: 700; + text-decoration: none; + white-space: nowrap; +} + +.tabs a:hover, +.tabs a[aria-current="page"], +.ai-tabs a:hover { + background: var(--lumi-surface); + color: var(--lumi-text); + box-shadow: var(--lumi-shadow-sm); +} + +details { + scroll-margin-top: 5rem; +} + +details > summary { + border-radius: var(--lumi-radius-sm); +} + +.empty-state, +.loading-state, +.error-state { + display: grid; + place-items: center; + min-height: 10rem; + padding: var(--lumi-space-6); + border: 1px dashed var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + color: var(--lumi-text-muted); + text-align: center; +} + +.loading-state::before { + content: ""; + width: 1.6rem; + height: 1.6rem; + border: 3px solid var(--lumi-border); + border-top-color: var(--lumi-primary); + border-radius: 50%; + animation: lumi-spin 0.75s linear infinite; +} + +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 0.5rem); + z-index: 100; + width: max-content; + max-width: 18rem; + padding: 0.4rem 0.55rem; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-text); + color: var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); + font-size: 0.8rem; + transform: translateX(-50%); +} + +@keyframes lumi-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 700px) { + .form-grid { + grid-template-columns: 1fr; + } + + .form-grid .field.full, + .form-grid h2 { + grid-column: auto; + } + + .card, + .panel, + .lumi-panel, + .modal { + padding: var(--lumi-space-4); + } + + .hero { + padding: var(--lumi-space-5); + } + + .table-tools, + .table-controls, + .log-controls { + align-items: stretch; + flex-direction: column; + } + + .table-tools > *, + .table-controls > *, + .log-controls > * { + width: 100%; + } + + .list li { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/web/public/lumi-layout.css b/src/web/public/lumi-layout.css new file mode 100644 index 0000000..42ee374 --- /dev/null +++ b/src/web/public/lumi-layout.css @@ -0,0 +1,260 @@ +html { + min-width: 320px; + background: var(--bg-2); + scroll-behavior: smooth; +} + +body { + font-family: var(--lumi-font-body); + font-size: 1rem; + line-height: 1.55; + background: + radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem), + radial-gradient(circle at 100% 100%, var(--bg-3) 0, transparent 38rem), + var(--bg-2); + background-attachment: fixed; +} + +.app-shell { + grid-template-columns: 17rem minmax(0, 1fr); +} + +.sidebar { + width: 17rem; + gap: var(--lumi-space-4); + padding: var(--lumi-space-4); + background: color-mix(in srgb, var(--lumi-surface) 94%, transparent); + border-color: var(--lumi-border); + box-shadow: var(--lumi-shadow-sm); + backdrop-filter: blur(18px); + z-index: 30; +} + +.sidebar-brand { + gap: var(--lumi-space-2); +} + +.brand-link { + min-height: 3rem; + padding: var(--lumi-space-2); +} + +.sidebar-nav { + gap: var(--lumi-space-2); + scrollbar-width: thin; + scrollbar-color: var(--lumi-border) transparent; +} + +.nav-section { + padding: var(--lumi-space-2); + border-radius: var(--lumi-radius-md); + background: transparent; +} + +.nav-section[open] { + background: var(--lumi-surface-subtle); +} + +.nav-section summary { + min-height: 2.5rem; + padding: var(--lumi-space-2); + border-radius: var(--lumi-radius-sm); +} + +.nav-section summary:hover { + background: var(--lumi-surface-raised); +} + +.nav-links { + gap: var(--lumi-space-1); + padding: var(--lumi-space-2) 0 0 1.75rem; +} + +.nav-link { + min-height: 2.5rem; + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 1px solid transparent; + border-radius: var(--lumi-radius-sm); +} + +.nav-link:hover { + border-color: var(--lumi-border); +} + +.nav-link.active { + background: color-mix(in srgb, var(--lumi-primary) 14%, var(--lumi-surface)); + border-color: color-mix(in srgb, var(--lumi-primary) 28%, var(--lumi-border)); + color: var(--lumi-text); +} + +.page { + min-width: 0; +} + +.content { + width: 100%; + max-width: var(--lumi-content-max); + margin: 0 auto; + padding: clamp(1rem, 2.5vw, 2.5rem); + gap: var(--lumi-space-5); +} + +.content > * { + min-width: 0; +} + +.site-footer { + width: 100%; + max-width: var(--lumi-content-max); + margin: auto auto 0; + padding: var(--lumi-space-5) clamp(1rem, 2.5vw, 2.5rem); + font-size: 0.875rem; +} + +.grid, +.lumi-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: var(--lumi-space-4); +} + +.lumi-stack { + display: flex; + flex-direction: column; + gap: var(--lumi-space-4); +} + +.lumi-cluster, +.button-group, +.page-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.lumi-split { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-4); +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-4); +} + +.page-header h1, +.section-header h1, +.section-header h2 { + margin-bottom: 0; +} + +.sidebar-scrim { + position: fixed; + inset: 0; + display: none; + background: rgba(5, 10, 12, 0.52); + backdrop-filter: blur(2px); + z-index: 25; +} + +.standalone-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--lumi-space-4); +} + +.standalone-card { + width: min(100%, 45rem); +} + +.standalone-detail { + margin-top: var(--lumi-space-4); +} + +@media (min-width: 901px) { + body.sidebar-collapsed .app-shell { + grid-template-columns: 5.5rem minmax(0, 1fr); + } + + body.sidebar-collapsed .sidebar { + width: 5.5rem; + } +} + +@media (max-width: 1100px) and (min-width: 901px) { + .app-shell { + grid-template-columns: 14rem minmax(0, 1fr); + } + + .sidebar { + width: 14rem; + } +} + +@media (max-width: 900px) { + .sidebar { + width: min(19rem, 88vw); + padding-top: var(--lumi-space-3); + } + + body.sidebar-open { + overflow: hidden; + } + + body.sidebar-open .sidebar-scrim { + display: block; + } + + .mobile-topbar { + min-height: 4rem; + padding: var(--lumi-space-3) var(--lumi-space-4); + background: color-mix(in srgb, var(--lumi-surface) 92%, transparent); + backdrop-filter: blur(18px); + } + + .content { + padding: var(--lumi-space-4); + } +} + +@media (max-width: 600px) { + .content { + gap: var(--lumi-space-4); + padding: var(--lumi-space-3); + } + + .site-footer { + padding: var(--lumi-space-5) var(--lumi-space-4); + } + + .section-header, + .commands-header, + .stats-header, + .lumi-split { + align-items: stretch; + flex-direction: column; + } + + .section-header > *, + .commands-header > *, + .stats-header > * { + width: 100%; + } + + .button-group, + .page-actions { + align-items: stretch; + } + + .button-group .button, + .page-actions .button { + flex: 1 1 auto; + } +} diff --git a/src/web/public/lumi-tokens.css b/src/web/public/lumi-tokens.css new file mode 100644 index 0000000..cbeab8d --- /dev/null +++ b/src/web/public/lumi-tokens.css @@ -0,0 +1,71 @@ +:root { + color-scheme: light dark; + --lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + --lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif; + --lumi-font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace; + + --lumi-text: var(--ink, #182026); + --lumi-text-muted: var(--ink-soft, #5a6872); + --lumi-primary: var(--sea, #176b75); + --lumi-accent: var(--sun, #e58b2b); + --lumi-danger: var(--rose, #bd4d4d); + --lumi-success: #23845b; + --lumi-warning: #a96612; + --lumi-info: #3479a8; + --lumi-link: var(--lumi-primary); + --lumi-surface: var(--card, #ffffff); + --lumi-surface-subtle: var(--surface-2, #f4f7f8); + --lumi-surface-raised: var(--surface-3, #edf2f3); + --lumi-border: var(--border, #d8e0e3); + --lumi-input-bg: var(--lumi-surface); + --lumi-input-border: var(--lumi-border); + --lumi-input-text: var(--lumi-text); + --lumi-button-bg: var(--lumi-primary); + --lumi-button-text: #ffffff; + --lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black); + --lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white); + + --lumi-space-scale: 1; + --lumi-space-1: calc(0.25rem * var(--lumi-space-scale)); + --lumi-space-2: calc(0.5rem * var(--lumi-space-scale)); + --lumi-space-3: calc(0.75rem * var(--lumi-space-scale)); + --lumi-space-4: calc(1rem * var(--lumi-space-scale)); + --lumi-space-5: calc(1.5rem * var(--lumi-space-scale)); + --lumi-space-6: calc(2rem * var(--lumi-space-scale)); + --lumi-space-7: calc(3rem * var(--lumi-space-scale)); + + --lumi-radius-sm: calc(var(--lumi-radius, 14px) * 0.58); + --lumi-radius-md: var(--lumi-radius, 14px); + --lumi-radius-lg: calc(var(--lumi-radius, 14px) * 1.42); + --lumi-radius-pill: 999px; + --lumi-shadow-sm: 0 1px 2px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 0.7)); + --lumi-shadow-md: 0 12px 34px rgba(11, 20, 24, var(--lumi-shadow-strength, 0.14)); + --lumi-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15)); + --lumi-transition: 150ms ease; + --lumi-control-height: 2.75rem; + --lumi-content-max: 1600px; + + /* Compatibility aliases for existing core and plugin styles. */ + --text: var(--lumi-text); + --muted: var(--lumi-text-muted); + --primary: var(--lumi-primary); + --accent: var(--lumi-accent); + --danger: var(--lumi-danger); + --success: var(--lumi-success); + --warning: var(--lumi-warning); + --info: var(--lumi-info); + --panel: var(--lumi-surface); + --panel-2: var(--lumi-surface-subtle); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/web/public/theme-editor.css b/src/web/public/theme-editor.css new file mode 100644 index 0000000..56ca52c --- /dev/null +++ b/src/web/public/theme-editor.css @@ -0,0 +1,381 @@ +.theme-library { + display: grid; + gap: var(--lumi-space-4); +} + +.theme-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--lumi-space-4); +} + +.theme-card { + display: flex; + flex-direction: column; + gap: var(--lumi-space-3); + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-sm); +} + +.theme-card.is-active { + border-color: var(--lumi-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 14%, transparent); +} + +.theme-swatch { + height: 7rem; + display: grid; + grid-template-columns: 1fr 2fr; + grid-template-rows: 1fr 1fr; + gap: var(--lumi-space-2); + padding: var(--lumi-space-3); + border: 1px solid color-mix(in srgb, var(--swatch-primary) 25%, transparent); + border-radius: var(--lumi-radius-sm); + background: var(--swatch-bg); +} + +.theme-swatch span { + border-radius: calc(var(--lumi-radius-sm) * 0.7); + background: var(--swatch-surface); + box-shadow: var(--lumi-shadow-sm); +} + +.theme-swatch span:first-child { + grid-row: 1 / -1; + background: var(--swatch-primary); +} + +.theme-swatch span:last-child { + width: 58%; + background: var(--swatch-accent); +} + +.theme-card-copy { + flex: 1; +} + +.theme-card-copy p { + margin: var(--lumi-space-2) 0 0; + color: var(--lumi-text-muted); +} + +.theme-card-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--lumi-space-2); +} + +.theme-card-title h3 { + margin-bottom: 0; +} + +.theme-kind { + flex: 0 0 auto; + padding: 0.25rem 0.45rem; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-pill); + color: var(--lumi-text-muted); + font-size: 0.7rem; + font-weight: 700; +} + +.theme-card-actions, +.theme-card-more-body { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.theme-card-actions form { + display: inline-flex; +} + +.theme-card-more { + border-top: 1px solid var(--lumi-border); + padding-top: var(--lumi-space-3); +} + +.theme-card-more summary { + cursor: pointer; + color: var(--lumi-link); + font-weight: 700; +} + +.theme-card-more-body { + align-items: flex-end; + margin-top: var(--lumi-space-3); +} + +.compact-form { + flex: 1 1 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--lumi-space-2); +} + +.compact-form label { + display: grid; + gap: var(--lumi-space-1); + font-weight: 700; +} + +.theme-editor-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 25rem); + align-items: start; + gap: var(--lumi-space-5); + scroll-margin-top: var(--lumi-space-4); +} + +.theme-editor-main { + display: grid; + gap: var(--lumi-space-5); +} + +.theme-edit-form { + display: grid; + gap: var(--lumi-space-5); +} + +.theme-fieldset { + min-width: 0; + margin: 0; + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.theme-fieldset legend { + padding: 0 var(--lumi-space-2); + font: 700 1.05rem/1 var(--lumi-font-display); +} + +.theme-control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--lumi-space-3); +} + +.theme-color-control, +.theme-range-control { + display: grid; + gap: var(--lumi-space-2); + font-weight: 700; +} + +.theme-color-input { + min-height: var(--lumi-control-height); + display: flex; + align-items: center; + gap: var(--lumi-space-2); + padding: 0.35rem; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.theme-color-input input { + width: 3.2rem; + height: 2.2rem; + padding: 0; + border: 0; +} + +.theme-color-input output { + color: var(--lumi-text-muted); + font: 600 0.8rem/1 var(--lumi-font-mono); +} + +.theme-range-control { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.theme-range-control > span { + display: flex; + justify-content: space-between; + gap: var(--lumi-space-2); +} + +.theme-range-control output { + color: var(--lumi-text-muted); +} + +.advanced-theme-controls { + margin-top: var(--lumi-space-4); +} + +.advanced-theme-controls summary { + cursor: pointer; + color: var(--lumi-link); + font-weight: 700; +} + +.advanced-theme-controls[open] summary { + margin-bottom: var(--lumi-space-3); +} + +.theme-editor-actions { + position: sticky; + bottom: var(--lumi-space-3); + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: color-mix(in srgb, var(--lumi-surface) 92%, transparent); + box-shadow: var(--lumi-shadow-md); + backdrop-filter: blur(16px); +} + +.theme-preview { + position: sticky; + top: var(--lumi-space-4); + display: grid; + gap: var(--lumi-space-3); +} + +.theme-preview-window { + min-height: 26rem; + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: + radial-gradient(circle at 20% 10%, var(--bg-1), transparent 55%), + var(--bg-2); +} + +.theme-preview-nav { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border-right: 1px solid var(--lumi-border); + background: var(--lumi-surface); +} + +.theme-preview-nav > span:not(.theme-preview-logo) { + width: 1.8rem; + height: 0.38rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-border); +} + +.theme-preview-logo { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-primary); + color: var(--lumi-button-text); + font-weight: 800; +} + +.theme-preview-content { + display: flex; + flex-direction: column; + gap: var(--lumi-space-3); + padding: var(--lumi-space-4); +} + +.theme-preview-heading { + width: 60%; + height: 1.2rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-text); +} + +.theme-preview-lines { + display: grid; + gap: var(--lumi-space-2); +} + +.theme-preview-lines span { + width: 82%; + height: 0.5rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-text-muted); + opacity: 0.45; +} + +.theme-preview-lines span:last-child { + width: 58%; +} + +.theme-preview-sample-card { + margin-top: var(--lumi-space-2); + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); +} + +.theme-preview-sample-card p { + color: var(--lumi-text-muted); +} + +.theme-preview-sample-card input { + margin-top: var(--lumi-space-3); +} + +.theme-preview-statuses { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-3); + margin-top: var(--lumi-space-3); + font-size: 0.8rem; + font-weight: 700; +} + +.is-selected { + border-color: var(--lumi-primary) !important; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important; +} + +@media (max-width: 1100px) { + .theme-editor-shell { + grid-template-columns: 1fr; + } + + .theme-preview { + position: static; + order: -1; + } + + .theme-preview-window { + min-height: 20rem; + } +} + +@media (max-width: 600px) { + .compact-form { + grid-template-columns: 1fr; + } + + .theme-editor-actions { + align-items: stretch; + flex-direction: column; + } + + .theme-editor-actions .button-group, + .theme-editor-actions .button { + width: 100%; + } +} diff --git a/src/web/public/theme-editor.js b/src/web/public/theme-editor.js new file mode 100644 index 0000000..295d858 --- /dev/null +++ b/src/web/public/theme-editor.js @@ -0,0 +1,89 @@ +(() => { + const editor = document.querySelector("[data-theme-editor]"); + const form = editor?.querySelector("[data-theme-form]"); + if (!editor || !form) return; + + const root = document.documentElement; + const originalScheme = root.dataset.colorScheme || ""; + const tokenVariables = { + bg1: "--bg-1", + bg2: "--bg-2", + bg3: "--bg-3", + text: "--ink", + muted: "--ink-soft", + accent: "--sea", + accentAlt: "--sun", + success: "--lumi-success", + warning: "--lumi-warning", + danger: "--rose", + info: "--lumi-info", + surface: "--card", + surface2: "--surface-2", + surface3: "--surface-3", + border: "--border", + link: "--lumi-link", + buttonBg: "--lumi-button-bg", + buttonText: "--lumi-button-text", + buttonHover: "--lumi-button-hover", + inputBg: "--lumi-input-bg", + inputBorder: "--lumi-input-border", + inputText: "--lumi-input-text", + focusRing: "--lumi-focus" + }; + const metricVariables = { + radius: ["--lumi-radius", "px"], + shadowStrength: ["--lumi-shadow-strength", ""], + spacingScale: ["--lumi-space-scale", ""] + }; + let previewMode = "light"; + + const updateOutputs = () => { + form.querySelectorAll('input[type="color"]').forEach((input) => { + const output = input.closest("label")?.querySelector("output"); + if (output) output.value = input.value.toUpperCase(); + }); + form.querySelectorAll("[data-theme-metric]").forEach((input) => { + const output = input.closest("label")?.querySelector("[data-range-output]"); + if (output) output.value = `${input.value}${input.dataset.unit || ""}`; + }); + }; + + const applyPreview = () => { + root.dataset.colorScheme = previewMode; + form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => { + const variable = tokenVariables[input.dataset.themeToken]; + if (variable) root.style.setProperty(variable, input.value); + }); + form.querySelectorAll("[data-theme-role]").forEach((input) => { + root.style.setProperty(`--role-${input.dataset.themeRole}`, input.value); + }); + form.querySelectorAll("[data-theme-metric]").forEach((input) => { + const config = metricVariables[input.dataset.themeMetric]; + if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`); + }); + updateOutputs(); + }; + + form.addEventListener("input", applyPreview); + editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => { + button.addEventListener("click", () => { + previewMode = button.dataset.themePreviewMode; + editor.querySelectorAll("[data-theme-preview-mode]").forEach((item) => { + item.classList.toggle("is-selected", item === button); + }); + applyPreview(); + }); + }); + + editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => { + form.reset(); + applyPreview(); + }); + + window.addEventListener("beforeunload", () => { + if (originalScheme) root.dataset.colorScheme = originalScheme; + else delete root.dataset.colorScheme; + }); + + applyPreview(); +})(); diff --git a/src/web/server.js b/src/web/server.js index b372332..a58f92e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -14,6 +14,17 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session); const { db } = require("../services/db"); const { getSetting, setSetting, getAllSettings } = require("../services/settings"); +const { + deleteCustomTheme, + duplicateTheme, + getActiveTheme, + getThemeById, + listThemes, + renameCustomTheme, + saveCustomTheme, + setActiveTheme, + valuesFromRequest +} = require("../services/themes"); const { getRoleFlags, hasAccess } = require("../services/rbac"); const { buildDiscordAuthUrl, @@ -1373,6 +1384,12 @@ function buildCustomPageSrcdoc(page, theme) { ` --bg-1: ${theme.light.bg1};`, ` --bg-2: ${theme.light.bg2};`, ` --bg-3: ${theme.light.bg3};`, + ` --success: ${theme.light.success};`, + ` --warning: ${theme.light.warning};`, + ` --info: ${theme.light.info};`, + ` --link: ${theme.light.link};`, + ` --radius: ${theme.metrics.radius}px;`, + ` --spacing-scale: ${theme.metrics.spacingScale};`, "}", "@media (prefers-color-scheme: dark) {", " :root {", @@ -1388,6 +1405,10 @@ function buildCustomPageSrcdoc(page, theme) { ` --bg-1: ${theme.dark.bg1};`, ` --bg-2: ${theme.dark.bg2};`, ` --bg-3: ${theme.dark.bg3};`, + ` --success: ${theme.dark.success};`, + ` --warning: ${theme.dark.warning};`, + ` --info: ${theme.dark.info};`, + ` --link: ${theme.dark.link};`, " }", "}" ].join("\n") @@ -1457,41 +1478,7 @@ function setFlash(req, type, message) { } function getThemeSettings() { - return { - light: { - bg1: getSetting("theme_light_bg_1", "#ffe5c4"), - bg2: getSetting("theme_light_bg_2", "#f4efe8"), - bg3: getSetting("theme_light_bg_3", "#e9f3f1"), - text: getSetting("theme_light_text", "#121518"), - muted: getSetting("theme_light_text_muted", "#2c3137"), - accent: getSetting("theme_light_accent", "#0f6a78"), - accentAlt: getSetting("theme_light_accent_alt", "#f4a340"), - danger: getSetting("theme_light_danger", "#d66d5c"), - surface: getSetting("theme_light_surface", "#ffffff"), - surface2: getSetting("theme_light_surface_2", "#fbf9f6"), - surface3: getSetting("theme_light_surface_3", "#f9f5ef"), - border: getSetting("theme_light_border", "#e3ddd6") - }, - dark: { - bg1: getSetting("theme_dark_bg_1", "#1b1d1f"), - bg2: getSetting("theme_dark_bg_2", "#16181b"), - bg3: getSetting("theme_dark_bg_3", "#0f1113"), - text: getSetting("theme_dark_text", "#f2f0ec"), - muted: getSetting("theme_dark_text_muted", "#c5bfb7"), - accent: getSetting("theme_dark_accent", "#4fb6c2"), - accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"), - danger: getSetting("theme_dark_danger", "#e08173"), - surface: getSetting("theme_dark_surface", "#232629"), - surface2: getSetting("theme_dark_surface_2", "#2b2f33"), - surface3: getSetting("theme_dark_surface_3", "#30353a"), - border: getSetting("theme_dark_border", "#34393d") - }, - role: { - public: getSetting("theme_role_public", "#ffffff"), - mod: getSetting("theme_role_mod", "#2cb678"), - admin: getSetting("theme_role_admin", "#e35678") - } - }; + return getActiveTheme(); } function getDiscordSettings() { @@ -3969,48 +3956,137 @@ function createWebServer({ loadPlugins, discordClient }) { }); app.get("/admin/theming", requireRole("admin"), (req, res) => { + const activeTheme = getActiveTheme(); + const requestedEdit = String(req.query.edit || ""); + const editingTheme = requestedEdit + ? getThemeById(requestedEdit) + : activeTheme.builtin + ? null + : activeTheme; res.render("admin-theme", { title: "Theming", - theme: getThemeSettings() + theme: activeTheme, + activeTheme, + themes: listThemes(), + editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null }); }); - app.post("/admin/theming", requireRole("admin"), (req, res) => { - const fields = [ - "theme_light_bg_1", - "theme_light_bg_2", - "theme_light_bg_3", - "theme_light_text", - "theme_light_text_muted", - "theme_light_accent", - "theme_light_accent_alt", - "theme_light_danger", - "theme_light_surface", - "theme_light_surface_2", - "theme_light_surface_3", - "theme_light_border", - "theme_dark_bg_1", - "theme_dark_bg_2", - "theme_dark_bg_3", - "theme_dark_text", - "theme_dark_text_muted", - "theme_dark_accent", - "theme_dark_accent_alt", - "theme_dark_danger", - "theme_dark_surface", - "theme_dark_surface_2", - "theme_dark_surface_3", - "theme_dark_border", - "theme_role_public", - "theme_role_mod", - "theme_role_admin" - ]; - for (const field of fields) { - if (req.body[field] !== undefined) { - setSetting(field, req.body[field].trim()); - } + app.post("/admin/theming/select", requireRole("admin"), (req, res) => { + try { + const theme = setActiveTheme(String(req.body.theme_id || "")); + setFlash(req, "success", `${theme.name} is now active.`); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/theming"); + }); + + app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => { + try { + const theme = duplicateTheme(req.body.theme_id, req.body.name); + setFlash(req, "success", `${theme.name} created. You can edit it below.`); + return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`); + } catch (error) { + setFlash(req, "error", error.message); + return res.redirect("/admin/theming"); + } + }); + + app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => { + const themeId = `custom:${req.params.id}`; + try { + const current = getThemeById(themeId); + if (!current) throw new Error("Theme not found."); + const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current)); + if (req.body.apply === "on") setActiveTheme(theme.id); + setFlash( + req, + "success", + req.body.apply === "on" + ? `${theme.name} saved and applied.` + : `${theme.name} saved.` + ); + } catch (error) { + const detail = error.validationErrors?.join(" ") || error.message; + setFlash(req, "error", detail); + } + res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`); + }); + + app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => { + const themeId = `custom:${req.params.id}`; + try { + const theme = renameCustomTheme(themeId, req.body.name); + setFlash(req, "success", `Theme renamed to ${theme.name}.`); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`); + }); + + app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => { + try { + deleteCustomTheme(`custom:${req.params.id}`); + setFlash(req, "success", "Custom theme deleted."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/theming"); + }); + + app.post("/admin/theming", requireRole("admin"), (req, res) => { + let createdTarget = null; + try { + let target = getActiveTheme(); + if (target.builtin) { + target = duplicateTheme(target.id, "Legacy Custom"); + createdTarget = target.id; + } + const values = JSON.parse(JSON.stringify(target)); + const legacyUpdates = []; + const legacyMap = { + theme_light_bg_1: ["light", "bg1"], + theme_light_bg_2: ["light", "bg2"], + theme_light_bg_3: ["light", "bg3"], + theme_light_text: ["light", "text"], + theme_light_text_muted: ["light", "muted"], + theme_light_accent: ["light", "accent"], + theme_light_accent_alt: ["light", "accentAlt"], + theme_light_danger: ["light", "danger"], + theme_light_surface: ["light", "surface"], + theme_light_surface_2: ["light", "surface2"], + theme_light_surface_3: ["light", "surface3"], + theme_light_border: ["light", "border"], + theme_dark_bg_1: ["dark", "bg1"], + theme_dark_bg_2: ["dark", "bg2"], + theme_dark_bg_3: ["dark", "bg3"], + theme_dark_text: ["dark", "text"], + theme_dark_text_muted: ["dark", "muted"], + theme_dark_accent: ["dark", "accent"], + theme_dark_accent_alt: ["dark", "accentAlt"], + theme_dark_danger: ["dark", "danger"], + theme_dark_surface: ["dark", "surface"], + theme_dark_surface_2: ["dark", "surface2"], + theme_dark_surface_3: ["dark", "surface3"], + theme_dark_border: ["dark", "border"], + theme_role_public: ["role", "public"], + theme_role_mod: ["role", "mod"], + theme_role_admin: ["role", "admin"] + }; + for (const [field, [group, key]] of Object.entries(legacyMap)) { + if (req.body[field] === undefined) continue; + values[group][key] = String(req.body[field]).trim(); + legacyUpdates.push([field, values[group][key]]); + } + saveCustomTheme(target.id, values); + for (const [field, value] of legacyUpdates) setSetting(field, value); + setActiveTheme(target.id); + setFlash(req, "success", "Theme updated."); + } catch (error) { + if (createdTarget) deleteCustomTheme(createdTarget); + setFlash(req, "error", error.message); } - setFlash(req, "success", "Theme updated."); res.redirect("/admin/theming"); }); diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 7ed105b..2778b5c 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -1,6 +1,10 @@ <%- include("partials/layout-top", { title }) %>
-

Admin dashboard

+ <%- include("partials/page-header", { + eyebrow: "Administration", + pageTitle: "Admin dashboard", + description: "Configure Lumi, manage community tools, and maintain the installation." + }) %>

Settings

@@ -9,8 +13,8 @@

Theming

-

Adjust light and dark mode colors.

- Edit theme +

Select protected presets or create editable custom themes.

+ Open theme studio

Commands

@@ -41,15 +45,17 @@

Maintenance

-
- -
-
- -
-
- -
+
+
+ +
+
+ +
+
+ +
+
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-pages.ejs b/src/web/views/admin-pages.ejs index 8269334..bfb7eea 100644 --- a/src/web/views/admin-pages.ejs +++ b/src/web/views/admin-pages.ejs @@ -1,6 +1,10 @@ <%- include("partials/layout-top", { title }) %>
-

Custom pages

+ <%- include("partials/page-header", { + eyebrow: "Content", + pageTitle: "Custom pages", + description: "Publish role-aware HTML or Markdown pages without changing routes." + }) %>
@@ -49,8 +53,9 @@

Existing pages

<% if (!pages.length) { %> -

No pages created yet.

+
No pages created yet.
<% } else { %> +
@@ -144,6 +149,7 @@ <% }) %>
+
<% } %>
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index cf498a5..32ab2e1 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -1,8 +1,11 @@ -<%- include("partials/layout-top", { title }) %> -
-

Updates

-

Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.

-

Rollback is handled from Safe Mode if something breaks.

+<%- include("partials/layout-top", { title }) %> +
+ <%- include("partials/page-header", { + eyebrow: "Maintenance", + pageTitle: "Updates", + description: "Apply git or ZIP updates with automatic pre-update snapshots." + }) %> +

Rollback is handled from Safe Mode if something breaks.

@@ -51,11 +54,12 @@
-

Snapshots

- <% if (!snapshots.length) { %> -

No snapshots yet.

- <% } else { %> - +

Snapshots

+ <% if (!snapshots.length) { %> +
No snapshots yet.
+ <% } else { %> +
+
@@ -70,7 +74,8 @@ <% }) %> -
Snapshot
- <% } %> -
+ +
+ <% } %> + <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-users.ejs b/src/web/views/admin-users.ejs index 4aa0d36..e635052 100644 --- a/src/web/views/admin-users.ejs +++ b/src/web/views/admin-users.ejs @@ -1,8 +1,12 @@ <%- include("partials/layout-top", { title }) %>
-

Users

+ <%- include("partials/page-header", { + eyebrow: "Community", + pageTitle: "Users", + description: "Review linked identities, notes, and internal usernames." + }) %> <% if (!users.length) { %> -

No users yet.

+
No users yet.
<% } else { %>
+
@@ -71,6 +76,7 @@ <% }) %>
+
<% } %>
+ <% } else { %> diff --git a/src/web/views/localhost-login.ejs b/src/web/views/localhost-login.ejs new file mode 100644 index 0000000..3d86844 --- /dev/null +++ b/src/web/views/localhost-login.ejs @@ -0,0 +1,23 @@ +<%- include("partials/layout-top", { title }) %> +
+ Development access +

Localhost Login

+

+ This login option is only available when the site is opened from localhost. + The default credentials are admin / admin until changed in settings. +

+
+
+ + +
+
+ + +
+
+ +
+
+
+<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index d5c95fe..b4e1b71 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -25,6 +25,7 @@ + diff --git a/src/web/views/partials/state-button.ejs b/src/web/views/partials/state-button.ejs new file mode 100644 index 0000000..dd4d9ec --- /dev/null +++ b/src/web/views/partials/state-button.ejs @@ -0,0 +1,36 @@ +<% + const stateButtonStates = (typeof states !== "undefined" && states) || [ + { id: "idle", text: (typeof text !== "undefined" && text) || "Submit" }, + { id: "loading", text: (typeof loadingText !== "undefined" && loadingText) || "Working...", spinner: true }, + { id: "success", text: (typeof successText !== "undefined" && successText) || "Done" } + ]; + const stateButtonDefault = (typeof defaultState !== "undefined" && defaultState) || "idle"; + const stateButtonAttrs = (typeof attrs !== "undefined" && attrs) || ""; + const stateButtonClass = `button lumi-state-btn ${(typeof classes !== "undefined" && classes) || ""}`.trim(); +%> + diff --git a/src/web/views/partials/theme-vars.ejs b/src/web/views/partials/theme-vars.ejs index 764cfcc..fc1570c 100644 --- a/src/web/views/partials/theme-vars.ejs +++ b/src/web/views/partials/theme-vars.ejs @@ -27,6 +27,12 @@ --lumi-radius: <%= theme.metrics.radius %>px; --lumi-shadow-strength: <%= theme.metrics.shadowStrength %>; --lumi-space-scale: <%= theme.metrics.spacingScale %>; + --lumi-font-body: <%- theme.typography.bodyFontStack %>; + --lumi-font-display: <%- theme.typography.displayFontStack %>; + --lumi-font-mono: <%- theme.typography.monoFontStack %>; + --lumi-font-size-base: <%= theme.typography.baseSize %>px; + --lumi-heading-scale: <%= theme.typography.headingScale %>; + --lumi-control-scale: <%= theme.typography.controlScale %>; --role-public: <%= theme.role.public %>; --role-mod: <%= theme.role.mod %>; --role-admin: <%= theme.role.admin %>; From 0d4431924a356b2906accf47bcddbc914bd0102d Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 02:49:15 +0200 Subject: [PATCH 05/10] ui: add streamed interactions and homepage controls --- docs/lumi-ui.md | 65 ++++ plugins/lumi_ai/backend/corrections.js | 5 + plugins/lumi_ai/backend/feedback.js | 10 +- plugins/lumi_ai/index.js | 56 +++- plugins/lumi_ai/public/assistant.css | 2 +- plugins/lumi_ai/public/assistant.js | 30 +- plugins/lumi_ai/public/settings.js | 1 + plugins/lumi_ai/views/improvement-center.ejs | 9 +- plugins/lumi_ai/views/settings.ejs | 21 +- plugins/throne_wishlist/public/admin.css | 4 +- plugins/throne_wishlist/views/admin.ejs | 23 +- scripts/verify-webui.js | 31 ++ src/services/web-events.js | 63 ++++ src/web/public/app.js | 5 +- src/web/public/lumi-components.css | 209 +++++++++++++ src/web/public/lumi-interactions.js | 299 +++++++++++++++++++ src/web/public/lumi-tokens.css | 7 + src/web/server.js | 135 ++++++++- src/web/views/admin-settings.ejs | 17 +- src/web/views/home.ejs | 29 ++ src/web/views/partials/layout-bottom.ejs | 1 + 21 files changed, 979 insertions(+), 43 deletions(-) create mode 100644 src/services/web-events.js create mode 100644 src/web/public/lumi-interactions.js diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index cf91b2e..5a657c3 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -16,6 +16,11 @@ from visual tokens and reusable components. - `src/web/public/lumi-state-button.js` and `src/web/views/partials/state-button.ejs`: reusable multi-state button behavior for submit/loading/success actions. +- `src/web/public/lumi-interactions.js`: progressive interaction layer for + server-sent events, no-auto-refresh notices, dirty settings save bars, + expandable settings containers, refresh prompts, and soft navigation. +- `src/services/web-events.js`: small role-aware Server-Sent Events bus exposed + at `GET /api/events` for authenticated users. - `src/web/public/styles.css`: legacy and feature-specific styles that still use the shared tokens. New general-purpose styling belongs in the Lumi UI files. - `src/web/views/partials/page-header.ejs`: standard page title and description. @@ -29,6 +34,35 @@ Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`, and `status-indicator` before adding one-off layout rules. Preserve existing IDs, field names, data attributes, and JavaScript hooks when restyling a page. +## Interaction Rules + +Pages should not self-refresh for state or progress changes. Core connection +recovery now displays a notice instead of calling `window.location.reload()`. +Server-originated events use `GET /api/events` with explicit event names such as +`server:status`, `server:warning`, `ai:model_status`, and +`data:new_available`. Admin-only events must be published with `{ role: "admin" }`. + +List/data updates should announce that new data exists and show a refresh prompt. +The shared refresh prompt uses a 3-second cooldown before another refresh can be +requested. It does not replace list contents automatically. + +Forms that represent page settings should add `data-lumi-settings-form`. +Action-only forms must not use that attribute. The shared dirty-state layer +tracks original values, marks changed fields with theme-aware unsaved styling, +shows a top Save changes bar, warns before accidental navigation, and clears +markers only after successful saves. + +Expandable settings rows use `data-lumi-expandable-settings` on a `
` +container. Preview text can be wired with `data-placeholder-preview="#field-id"`; +known placeholders such as `{gifter_username}`, `{item_name}`, +`{creator_username}`, and `{amount_display}` render with plausible sample values +without changing the saved template. + +Soft navigation progressively enhances same-origin links by replacing +`main.content`, updating history, and fading content in place. If a fetch fails, +JavaScript is unavailable, or unsaved settings are present, navigation falls back +to normal browser behavior. + ## Themes Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High @@ -74,6 +108,37 @@ Admins can change the localhost username and password from **Admin > Settings** when the settings page itself is accessed through localhost. Leaving the password field blank keeps the existing password. +## Lumi AI Settings And Feedback + +Lumi AI's main Selected model dropdown lists only installed/downloaded models. +If the configured model is missing, the settings page shows a warning and saving +requires selecting an installed model. Main context size is a preset dropdown: +Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported +freeform context values are rejected server-side. + +AI feedback supports `feedback_kind` values `strict_correction` and +`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where +the model called the wrong tool or failed to call an expected tool. Review, +edit, and implementation views show both the kind and tag so admins can tell +direct answer corrections from broader tool-calling or instruction guidance. + +## Homepage Content + +Admins can define homepage external link buttons in `homepage_link_buttons` from +Admin > Settings. Each entry may include `enabled`, `label`, `description`, +`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and +`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are +filtered server-side by permission. + +Admins can define priority-based hero entries in `homepage_hero_entries`. +Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`, +`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and +`none`. The homepage renders the first enabled, available entry the current user +can access. Hero entries support priority/order, permission, source/embed/image +URLs, video IDs, availability mode, autoplay mode metadata, and duration fields. +Slow external availability checks are intentionally avoided; entries fail +closed if required local configuration is missing. + ## Visual references - [Home, desktop](screenshots/lumi-home-desktop.png) diff --git a/plugins/lumi_ai/backend/corrections.js b/plugins/lumi_ai/backend/corrections.js index fbb2a9c..378c106 100644 --- a/plugins/lumi_ai/backend/corrections.js +++ b/plugins/lumi_ai/backend/corrections.js @@ -36,6 +36,8 @@ class CorrectionStore { const entry = { id: crypto.randomUUID(), source_feedback_id: feedback.id, + feedback_kind: feedback.feedback_kind || "strict_correction", + feedback_tag: feedback.feedback_tag || "", prompt: feedback.user_message, corrected_answer: answer, rejected_answer: feedback.assistant_answer, @@ -166,6 +168,9 @@ class CorrectionStore { .filter((entry) => ["correction", "route_alias", "predefined_answer"].includes(entry.target)) .map((entry) => [ `Reviewed correction for a similar request (minimum role: ${entry.min_role}):`, + entry.feedback_kind === "instruction_based" || entry.feedback_tag === "wrong_tool_usage" + ? `Revision guidance: ${entry.feedback_tag === "wrong_tool_usage" ? "tool-calling behavior" : "instruction"}` + : "", `Request: ${entry.prompt}`, `Approved answer: ${entry.corrected_answer}`, entry.expected_link ? `Verified link: ${entry.expected_link}` : "" diff --git a/plugins/lumi_ai/backend/feedback.js b/plugins/lumi_ai/backend/feedback.js index 65c116b..755f3cd 100644 --- a/plugins/lumi_ai/backend/feedback.js +++ b/plugins/lumi_ai/backend/feedback.js @@ -12,9 +12,12 @@ const FEEDBACK_TAGS = Object.freeze([ "unsafe", "should_clarify", "bad_code", - "wrong_scope" + "wrong_scope", + "wrong_tool_usage" ]); +const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]); + class FeedbackStore { constructor(options = {}) { this.file = options.file || resolveData("feedback", "reviews.json"); @@ -34,6 +37,9 @@ class FeedbackStore { model: clean(input.model, 200), timestamp: validDate(input.timestamp) || new Date().toISOString(), feedback_tag: tag, + feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) + ? input.feedback_kind + : "strict_correction", optional_correction: clean(input.optional_correction, 16000), status: "pending", submitted_by: String(actor?.id || "anonymous"), @@ -70,6 +76,7 @@ class FeedbackStore { return this.mutate(id, (entry) => ({ ...entry, feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag, + feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction", optional_correction: clean(values.optional_correction, 16000), review_notes: clean(values.review_notes, 4000), reviewed_by: String(actor.id), @@ -196,6 +203,7 @@ function validDate(value) { } module.exports = { + FEEDBACK_KINDS, FEEDBACK_TAGS, FeedbackStore, improvementAccess, diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 08e28ea..3b93515 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -25,7 +25,7 @@ const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits"); const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands"); const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics"); const { formatAssistantResponse } = require("./backend/response_formatter"); -const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback"); +const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback"); const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections"); const { EvalStore } = require("./backend/evals"); const { TrainingExporter } = require("./backend/training_export"); @@ -39,6 +39,12 @@ const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); const PLUGIN_ID = "lumi_ai"; +const CONTEXT_OPTIONS = Object.freeze([ + { label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." }, + { label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." }, + { label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." }, + { label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." } +]); const modelManifest = require("./models_manifest.json"); const runtimeManifest = require("./runtime_manifest.json"); @@ -155,6 +161,10 @@ module.exports = { if (config.enabled) { ensureGateRuntime().catch((error) => { metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_start_failed", message: error.message }); + web.emitEvent?.("ai:model_status", { + status: "gate_start_failed", + message: `Lumi AI gate runtime failed to start: ${error.message}` + }, { role: "admin" }); }); } const main = await ensureMainRuntime(options); @@ -171,6 +181,10 @@ module.exports = { try { await ensureGateRuntime(); } catch (error) { metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_restart_failed", message: error.message }); + web.emitEvent?.("ai:model_status", { + status: "gate_restart_failed", + message: `Lumi AI gate runtime restart failed: ${error.message}` + }, { role: "admin" }); } } return { ...main, gate: await gateRuntime.health() }; @@ -184,7 +198,12 @@ module.exports = { ) return; gateRecoveryPending = true; try { await ensureGateRuntime(); } - catch {} + catch (error) { + web.emitEvent?.("ai:model_status", { + status: "gate_recovery_failed", + message: `Lumi AI gate recovery failed: ${error.message}` + }, { role: "admin" }); + } finally { gateRecoveryPending = false; } }, 30000); gateMonitor.unref?.(); @@ -287,17 +306,22 @@ module.exports = { sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3) ].filter((check) => !check.valid); for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`); + const models = modelManifest.models.map((model) => ({ + ...model, + downloaded: fs.existsSync(resolveData("models", model.filename)), + installed_size: fs.existsSync(resolveData("models", model.filename)) + ? fs.statSync(resolveData("models", model.filename)).size + : 0, + compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb + })); + const installedModels = models.filter((model) => model.downloaded); res.render(path.join(__dirname, "views", "settings.ejs"), { title: "Lumi AI", config, - models: modelManifest.models.map((model) => ({ - ...model, - downloaded: fs.existsSync(resolveData("models", model.filename)), - installed_size: fs.existsSync(resolveData("models", model.filename)) - ? fs.statSync(resolveData("models", model.filename)).size - : 0, - compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb - })), + models, + installedModels, + selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id), + contextOptions: CONTEXT_OPTIONS, runtimeTarget, runtimeManifest, runtimeStatus, @@ -338,7 +362,15 @@ module.exports = { if (!req.session.user?.isAdmin) return denied(res); const model = getModel(req.body.selected_model_id); if (!model) return flash(req, res, "error", "Unknown model."); - const contextSize = boundedInt(req.body.context_size, 512, 131072, 4096); + if (!fs.existsSync(resolveData("models", model.filename))) { + return flash(req, res, "error", "Selected model must be installed before it can be saved."); + } + const contextValues = CONTEXT_OPTIONS.map((option) => option.value); + const requestedContext = Number(req.body.context_size); + if (!contextValues.includes(requestedContext)) { + return flash(req, res, "error", "Choose a supported AI context size."); + } + const contextSize = requestedContext; const previousConfig = config; config = saveConfig({ ...config, @@ -891,6 +923,7 @@ module.exports = { model: req.body.model, timestamp: req.body.timestamp, feedback_tag: req.body.feedback_tag, + feedback_kind: req.body.feedback_kind, optional_correction: req.body.optional_correction }, req.session.user); return res.status(201).json({ success: true, id: entry.id }); @@ -1148,6 +1181,7 @@ module.exports = { config, access, feedbackTags: FEEDBACK_TAGS, + feedbackKinds: FEEDBACK_KINDS, promotionTargets: PROMOTION_TARGETS, reviews: feedbackStore.list({ page: req.query.review_page, diff --git a/plugins/lumi_ai/public/assistant.css b/plugins/lumi_ai/public/assistant.css index 27b2839..916cfd2 100644 --- a/plugins/lumi_ai/public/assistant.css +++ b/plugins/lumi_ai/public/assistant.css @@ -8,7 +8,7 @@ .lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); } .lumi-ai-state.warming { background: #d29922; box-shadow: 0 0 0 3px color-mix(in srgb, #d29922 18%, transparent); } .lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); } -.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 16px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; } +.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; bottom: 14px; height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 96px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; } .lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; } .lumi-ai-resize-handle { position: relative; cursor: ns-resize; background: var(--surface-2); touch-action: none; } .lumi-ai-resize-handle::after { content: ""; position: absolute; top: 3px; left: 50%; width: 42px; height: 2px; transform: translateX(-50%); border-radius: 2px; background: var(--border); } diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js index d2da01a..2219241 100644 --- a/plugins/lumi_ai/public/assistant.js +++ b/plugins/lumi_ai/public/assistant.js @@ -65,15 +65,8 @@ }; const positionPanel = (height = panelHeight()) => { - const viewportHeight = window.innerHeight; - const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect(); - const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0 - ? Math.max(MIN_HEIGHT + 8, footerRect.top - 8) - : viewportHeight - 8; - const maximum = Math.max(MIN_HEIGHT, bottomLimit - 8); + const maximum = Math.max(MIN_HEIGHT, window.innerHeight - 96); const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height)); - const top = Math.max(8, bottomLimit - clampedHeight); - panel.style.setProperty("--lumi-ai-top", `${top}px`); panel.style.height = `${clampedHeight}px`; return clampedHeight; }; @@ -296,21 +289,33 @@ "unsafe", "should_clarify", "bad_code", - "wrong_scope" + "wrong_scope", + "wrong_tool_usage" ]) { const option = document.createElement("option"); option.value = tag; option.textContent = tag.replaceAll("_", " "); select.append(option); } + const kind = document.createElement("select"); + kind.setAttribute("aria-label", "Feedback type"); + for (const [value, label] of [ + ["strict_correction", "Strict correction"], + ["instruction_based", "Instruction-based guidance"] + ]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + kind.append(option); + } const correction = document.createElement("input"); correction.maxLength = 16000; - correction.placeholder = "Optional correction"; - correction.setAttribute("aria-label", "Optional correction"); + correction.placeholder = "Correction or instruction for future replies"; + correction.setAttribute("aria-label", "Correction or instruction"); const submitFeedback = document.createElement("button"); submitFeedback.type = "submit"; submitFeedback.textContent = "Send feedback"; - controls.append(select, correction, submitFeedback); + controls.append(select, kind, correction, submitFeedback); controls.addEventListener("submit", async (event) => { event.preventDefault(); submitFeedback.disabled = true; @@ -321,6 +326,7 @@ body: JSON.stringify({ ...context, feedback_tag: select.value, + feedback_kind: kind.value, optional_correction: correction.value.trim() }) }); diff --git a/plugins/lumi_ai/public/settings.js b/plugins/lumi_ai/public/settings.js index 3d8d16e..01e520b 100644 --- a/plugins/lumi_ai/public/settings.js +++ b/plugins/lumi_ai/public/settings.js @@ -162,6 +162,7 @@ workload.addEventListener("change", refreshCapacity); model.addEventListener("change", refreshCapacity); context.addEventListener("input", scheduleCapacity); + context.addEventListener("change", refreshCapacity); refreshCapacity(); } if (accessForm) { diff --git a/plugins/lumi_ai/views/improvement-center.ejs b/plugins/lumi_ai/views/improvement-center.ejs index dd0d2aa..bbe0e39 100644 --- a/plugins/lumi_ai/views/improvement-center.ejs +++ b/plugins/lumi_ai/views/improvement-center.ejs @@ -43,14 +43,14 @@ <% reviews.entries.forEach((review) => { %>
-
<%= review.feedback_tag %> <%= review.status %>
+
<%= review.feedback_tag %> <%= review.feedback_kind || "strict_correction" %> <%= review.status %>
<%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %>
User message
<%= review.user_message %>
Assistant answer
<%= review.assistant_answer %>
- <% if (review.optional_correction) { %>
Suggested correction
<%= review.optional_correction %>
<% } %> + <% if (review.optional_correction) { %>
<%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %>
<%= review.optional_correction %>
<% } %> <% if (review.review_notes) { %>

Review notes: <%= review.review_notes %>

<% } %>
<% if (access.can_flag) { %> @@ -93,7 +93,8 @@
-
+
+
@@ -105,7 +106,7 @@
-
+
" />
" />
diff --git a/plugins/lumi_ai/views/settings.ejs b/plugins/lumi_ai/views/settings.ejs index 9b0420f..13c1501 100644 --- a/plugins/lumi_ai/views/settings.ejs +++ b/plugins/lumi_ai/views/settings.ejs @@ -202,7 +202,7 @@
-
+

Assistant

Configuration remains admin-only. Visibility controls only the sidebar assistant.

@@ -222,9 +222,24 @@
- + <% if (!selectedModelInstalled) { %> +
The currently selected model is not installed. Choose an installed model before saving.
+ <% } %> + +
+
+ +
-
diff --git a/plugins/throne_wishlist/public/admin.css b/plugins/throne_wishlist/public/admin.css index 2b8b5b8..9f5f641 100644 --- a/plugins/throne_wishlist/public/admin.css +++ b/plugins/throne_wishlist/public/admin.css @@ -21,7 +21,7 @@ .diagnostic-grid > div, .destination-panel, -.template-panel { +.template-panel:not(.lumi-expandable-settings) { border: 1px solid var(--border); background: var(--surface-2); padding: 14px; @@ -99,7 +99,7 @@ padding-top: 12px; } -.template-panel { +.template-panel:not(.lumi-expandable-settings) { display: grid; gap: 10px; } diff --git a/plugins/throne_wishlist/views/admin.ejs b/plugins/throne_wishlist/views/admin.ejs index 863b08f..8a525a3 100644 --- a/plugins/throne_wishlist/views/admin.ejs +++ b/plugins/throne_wishlist/views/admin.ejs @@ -164,10 +164,22 @@

No active platform templates are available.

<% } %> <% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %> - +
+ + + <%= status?.label || platform %> + "> + + + + -
+
<%= status?.label || platform %>
- - +
+ + +
+
<% }) %>
diff --git a/scripts/verify-webui.js b/scripts/verify-webui.js index bebac98..6c54a28 100644 --- a/scripts/verify-webui.js +++ b/scripts/verify-webui.js @@ -101,6 +101,37 @@ function verifyThemeService() { assert(rendered.includes("data-lumi-state-button")); assert(rendered.includes("Built-in · read-only")); + const homeView = path.join(root, "src", "web", "views", "home.ejs"); + const homeRendered = ejs.render(fs.readFileSync(homeView, "utf8"), { + title: "Home", + siteTitle: "Lumi Bot", + assetVersion: "verify", + theme: renamed, + botAvatar: null, + navSections: [], + user: { username: "Admin" }, + userAvatar: null, + userInitial: "A", + platformLogins: [], + flash: null, + softError: null, + homepageLinks: [{ + label: "Twitch", + description: "Watch live", + url: "https://example.com", + fallback_icon: "T" + }], + homepageHero: { + type: "static_image", + title: "Featured", + description: "Featured content", + image_url: "https://example.com/hero.png", + source_url: "https://example.com" + } + }, { filename: homeView }); + assert(homeRendered.includes("homepage-link-button")); + assert(homeRendered.includes("homepage-dynamic-hero")); + const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs"); const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), { title: "Localhost Login", diff --git a/src/services/web-events.js b/src/services/web-events.js new file mode 100644 index 0000000..a0ad583 --- /dev/null +++ b/src/services/web-events.js @@ -0,0 +1,63 @@ +const clients = new Map(); + +function subscribe(req, res) { + const id = `${Date.now()}:${Math.random().toString(16).slice(2)}`; + const user = req.session?.user || null; + const client = { id, res, user, connectedAt: Date.now() }; + clients.set(id, client); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no" + }); + send(client, "server:status", { + status: "connected", + message: "Lumi event stream connected.", + connected_at: client.connectedAt + }); + + const keepAlive = setInterval(() => { + send(client, "server:status", { status: "heartbeat", at: Date.now() }); + }, 25000); + + req.on("close", () => { + clearInterval(keepAlive); + clients.delete(id); + }); +} + +function publish(event, payload = {}, options = {}) { + let delivered = 0; + for (const client of clients.values()) { + if (!canReceive(client.user, options)) continue; + send(client, event, payload); + delivered += 1; + } + return delivered; +} + +function send(client, event, payload) { + try { + client.res.write(`event: ${event}\n`); + client.res.write(`data: ${JSON.stringify({ ...payload, event, at: Date.now() })}\n\n`); + } catch { + clients.delete(client.id); + } +} + +function canReceive(user, options = {}) { + const role = options.role || "public"; + if (role === "public") return true; + if (!user) return false; + if (role === "user") return true; + if (role === "mod") return Boolean(user.isMod || user.isAdmin); + if (role === "admin") return Boolean(user.isAdmin); + return false; +} + +module.exports = { + publishWebEvent: publish, + subscribeWebEvents: subscribe +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index cad0e8d..b05a3d4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -489,8 +489,9 @@ const response = await fetch(healthEndpoint, { cache: "no-store" }); if (response.ok) { if (connectionLost) { - window.location.reload(); - return; + window.LumiInteractions?.showEventNotice?.({ + message: "Connection restored. Refresh manually if you need newer page data." + }, "info"); } connectionLost = false; } else { diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 1da78b3..2e72ab0 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -90,6 +90,65 @@ pre { z-index: 1; } +.homepage-dynamic-hero { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(18rem, 1.1fr); + align-items: center; + gap: var(--lumi-space-5); +} + +.homepage-hero-media { + width: 100%; + min-height: 18rem; + aspect-ratio: 16 / 9; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + object-fit: cover; +} + +.homepage-link-strip { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-3); +} + +.homepage-link-button { + flex: 1 1 14rem; + min-height: var(--lumi-control-height); + display: flex; + align-items: center; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + color: var(--lumi-text); + box-shadow: var(--lumi-shadow-sm); + text-decoration: none; +} + +.homepage-link-button:hover { + border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border)); + background: var(--lumi-surface-raised); +} + +.homepage-link-button img, +.homepage-link-button > span:first-child { + width: 2rem; + height: 2rem; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); +} + +.homepage-link-button small { + display: block; + color: var(--lumi-text-muted); +} + .card, .panel, .lumi-panel { @@ -425,6 +484,126 @@ input[type="color"] { background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface)); } +.is-unsaved { + border-color: var(--lumi-color-unsaved-border) !important; + background: var(--lumi-color-unsaved-bg) !important; + color: var(--lumi-color-unsaved-text); + box-shadow: 0 0 0 2px var(--lumi-color-unsaved-ring); +} + +.lumi-savebar { + position: fixed; + top: var(--lumi-space-3); + left: 50%; + z-index: 80; + width: min(42rem, calc(100vw - 2rem)); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-savebar-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-savebar-bg); + box-shadow: var(--lumi-savebar-shadow); + backdrop-filter: blur(18px); + transform: translate(-50%, 0); + transition: opacity var(--lumi-transition), transform var(--lumi-transition); +} + +.lumi-savebar[hidden] { + display: none; +} + +.lumi-savebar.is-hidden-by-scroll { + opacity: 0; + pointer-events: none; + transform: translate(-50%, -110%); +} + +.lumi-savebar.has-error { + border-color: var(--lumi-danger); +} + +.lumi-event-notices { + position: fixed; + right: var(--lumi-space-4); + bottom: var(--lumi-space-4); + z-index: 90; + display: grid; + gap: var(--lumi-space-2); + width: min(24rem, calc(100vw - 2rem)); +} + +.lumi-event-notice, +.lumi-refresh-prompt { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); +} + +.lumi-event-notice.warning { + border-color: color-mix(in srgb, var(--lumi-warning) 50%, var(--lumi-border)); +} + +.lumi-event-notice.danger { + border-color: color-mix(in srgb, var(--lumi-danger) 55%, var(--lumi-border)); +} + +.lumi-refresh-prompt { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--lumi-space-3); +} + +.lumi-expandable-settings { + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-sm); + overflow: clip; +} + +.lumi-expandable-settings + .lumi-expandable-settings { + margin-top: var(--lumi-space-3); +} + +.lumi-expandable-settings summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--lumi-space-3); + align-items: center; + padding: var(--lumi-space-3); + cursor: pointer; +} + +.lumi-expandable-settings[open] summary { + border-bottom: 1px solid var(--lumi-border); +} + +.lumi-expandable-body { + padding: var(--lumi-space-4); +} + +.lumi-preview-line { + color: var(--lumi-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.content.is-soft-loading { + opacity: 0.55; + transition: opacity 90ms ease; +} + +.content.is-soft-loaded { + animation: lumi-soft-in 140ms ease; +} + .list li { border: 1px solid var(--lumi-border); border-radius: var(--lumi-radius-sm); @@ -550,6 +729,17 @@ details > summary { } } +@keyframes lumi-soft-in { + from { + opacity: 0.45; + transform: translateY(0.25rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @media (max-width: 700px) { h1 { font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale)); @@ -586,6 +776,14 @@ details > summary { top: -5rem; } + .homepage-dynamic-hero { + grid-template-columns: 1fr; + } + + .homepage-hero-media { + min-height: 12rem; + } + .button, button.button, input[type="submit"].button, @@ -602,6 +800,17 @@ details > summary { padding: 0.55rem 0.8rem; } + .lumi-savebar, + .lumi-refresh-prompt { + align-items: stretch; + flex-direction: column; + } + + .lumi-event-notices { + right: var(--lumi-space-3); + bottom: var(--lumi-space-3); + } + .table-tools, .table-controls, .log-controls { diff --git a/src/web/public/lumi-interactions.js b/src/web/public/lumi-interactions.js new file mode 100644 index 0000000..43e84a6 --- /dev/null +++ b/src/web/public/lumi-interactions.js @@ -0,0 +1,299 @@ +(() => { + const initializedForms = new WeakSet(); + const initializedExpandables = new WeakSet(); + const REFRESH_COOLDOWN_MS = 3000; + let saveBar = null; + let stream = null; + let lastScrollY = window.scrollY; + + const init = (root = document) => { + initSettingsDirty(root); + initExpandables(root); + initSoftNavigation(root); + }; + + const ensureSaveBar = () => { + if (saveBar) return saveBar; + saveBar = document.createElement("div"); + saveBar.className = "lumi-savebar"; + saveBar.hidden = true; + saveBar.innerHTML = ` +
+ Unsaved changes + Review and save changed settings on this page. +
+ + `; + document.body.append(saveBar); + saveBar.querySelector("[data-savebar-submit]").addEventListener("click", saveDirtyForms); + window.addEventListener("scroll", updateSaveBarScroll, { passive: true }); + return saveBar; + }; + + const formFields = (form) => Array.from(form.elements).filter((field) => + field.name && !field.disabled && !["submit", "button", "reset", "file"].includes(field.type) + ); + + const fieldValue = (field) => { + if (field.type === "checkbox") return field.checked ? "on" : ""; + if (field.type === "radio") return field.checked ? field.value : ""; + return field.value; + }; + + const snapshotForm = (form) => { + const snapshot = new Map(); + for (const field of formFields(form)) { + if (field.type === "radio") { + if (!snapshot.has(field.name)) snapshot.set(field.name, ""); + if (field.checked) snapshot.set(field.name, field.value); + } else { + snapshot.set(field.name, fieldValue(field)); + } + } + return snapshot; + }; + + const isFieldDirty = (field, snapshot) => { + const original = snapshot.get(field.name) || ""; + if (field.type === "radio") { + return field.checked && field.value !== original; + } + return fieldValue(field) !== original; + }; + + const updateDirtyState = () => { + const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form]")); + let dirtyCount = 0; + for (const form of forms) { + const snapshot = form._lumiSnapshot || snapshotForm(form); + let formDirty = false; + for (const field of formFields(form)) { + const dirty = isFieldDirty(field, snapshot); + const container = field.closest(".field, .theme-color-control, .theme-range-control, .theme-select-control, fieldset"); + container?.classList.toggle("is-unsaved", dirty); + formDirty = formDirty || dirty; + if (dirty) dirtyCount += 1; + } + form.classList.toggle("has-unsaved-settings", formDirty); + } + const bar = ensureSaveBar(); + bar.hidden = dirtyCount === 0; + bar.classList.toggle("is-visible", dirtyCount > 0); + bar.querySelector("[data-savebar-count]").textContent = + dirtyCount === 1 ? "1 unsaved setting" : `${dirtyCount} unsaved settings`; + if (dirtyCount === 0) bar.querySelector("[data-savebar-status]").textContent = "Saved."; + updateSaveBarScroll(); + }; + + function initSettingsDirty(root) { + root.querySelectorAll?.("form[data-lumi-settings-form]").forEach((form) => { + if (initializedForms.has(form)) return; + initializedForms.add(form); + form._lumiSnapshot = snapshotForm(form); + form.addEventListener("input", updateDirtyState); + form.addEventListener("change", updateDirtyState); + form.addEventListener("submit", () => { + form._lumiSnapshot = snapshotForm(form); + window.setTimeout(updateDirtyState, 0); + }); + }); + if (document.querySelector("form[data-lumi-settings-form]")) { + ensureSaveBar(); + updateDirtyState(); + window.addEventListener("beforeunload", warnDirtyNavigation); + } + } + + async function saveDirtyForms() { + const bar = ensureSaveBar(); + const button = bar.querySelector("[data-savebar-submit]"); + const status = bar.querySelector("[data-savebar-status]"); + const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings")); + if (!forms.length) return; + button.disabled = true; + status.textContent = "Saving..."; + try { + for (const form of forms) { + const response = await fetch(form.action || window.location.href, { + method: form.method || "POST", + body: new FormData(form), + headers: { Accept: "text/html,application/json" }, + redirect: "follow" + }); + if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`); + form._lumiSnapshot = snapshotForm(form); + } + status.textContent = "Saved."; + updateDirtyState(); + } catch (error) { + status.textContent = error.message || "Save failed."; + bar.classList.add("has-error"); + } finally { + button.disabled = false; + window.setTimeout(() => bar.classList.remove("has-error"), 2500); + } + } + + function warnDirtyNavigation(event) { + if (!document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return; + event.preventDefault(); + event.returnValue = ""; + } + + function updateSaveBarScroll() { + if (!saveBar || saveBar.hidden) return; + const canScroll = document.documentElement.scrollHeight > window.innerHeight + 12; + const next = window.scrollY; + saveBar.classList.toggle("is-hidden-by-scroll", canScroll && next > lastScrollY + 4); + lastScrollY = next; + } + + function initExpandables(root) { + root.querySelectorAll?.("[data-lumi-expandable-settings]").forEach((item) => { + if (initializedExpandables.has(item)) return; + initializedExpandables.add(item); + item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview); + item.addEventListener("input", () => { + item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview); + }); + }); + } + + function updatePlaceholderPreview(target) { + const source = target.closest("[data-lumi-expandable-settings]")?.querySelector(target.dataset.placeholderPreview); + const text = source?.value || target.dataset.fallback || ""; + const replacements = { + gifter_username: "SomeUser123", + item_name: "Cool Item", + creator_username: "CreatorName", + amount_display: "$12.34", + username: "SomeUser123", + platform: "Twitch" + }; + target.textContent = text.replace(/\{([^{}]+)\}/g, (full, key) => replacements[key] || full); + } + + function connectEvents() { + if (!window.EventSource || stream) return; + stream = new EventSource("/api/events"); + stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning")); + stream.addEventListener("server:status", (event) => { + const data = readEvent(event); + if (data.status === "connected") document.body.dataset.eventStream = "connected"; + }); + stream.addEventListener("ai:model_status", (event) => showEventNotice(readEvent(event), "danger")); + stream.addEventListener("data:new_available", (event) => showRefreshPrompt(readEvent(event))); + stream.onerror = () => { + document.body.dataset.eventStream = "disconnected"; + }; + } + + function readEvent(event) { + try { return JSON.parse(event.data || "{}"); } catch { return {}; } + } + + function noticeRoot() { + let root = document.querySelector("[data-lumi-event-notices]"); + if (!root) { + root = document.createElement("div"); + root.className = "lumi-event-notices"; + root.dataset.lumiEventNotices = ""; + document.body.append(root); + } + return root; + } + + function showEventNotice(data, tone = "info") { + const item = document.createElement("div"); + item.className = `lumi-event-notice ${tone}`; + item.setAttribute("role", tone === "danger" ? "alert" : "status"); + item.textContent = data.message || data.status || "Lumi status changed."; + noticeRoot().append(item); + window.setTimeout(() => item.remove(), 9000); + } + + function showRefreshPrompt(data) { + const item = document.createElement("div"); + item.className = "lumi-refresh-prompt"; + item.setAttribute("role", "status"); + const label = document.createElement("span"); + label.textContent = data.message || "New data is available."; + const button = document.createElement("button"); + button.type = "button"; + button.className = "button subtle"; + button.textContent = "Refresh"; + button.addEventListener("click", () => { + button.disabled = true; + window.setTimeout(() => { button.disabled = false; }, REFRESH_COOLDOWN_MS); + if (data.url) window.location.assign(data.url); + else window.location.reload(); + }); + item.append(label, button); + noticeRoot().append(item); + } + + function initSoftNavigation(root) { + root.querySelectorAll?.("a[href]").forEach((link) => { + if (link.dataset.softNavBound || link.target || link.hasAttribute("download")) return; + const url = new URL(link.href, window.location.href); + if (url.origin !== window.location.origin || url.pathname.startsWith("/auth/")) return; + link.dataset.softNavBound = "true"; + link.addEventListener("click", (event) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const main = document.querySelector("main.content"); + if (!main || document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return; + event.preventDefault(); + softNavigate(url.href); + }); + }); + } + + async function softNavigate(url, push = true) { + const main = document.querySelector("main.content"); + if (!main) return window.location.assign(url); + main.classList.add("is-soft-loading"); + try { + const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" } }); + if (!response.ok) throw new Error("Navigation failed."); + const html = await response.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + const nextMain = doc.querySelector("main.content"); + if (!nextMain) throw new Error("Navigation target did not contain page content."); + document.title = doc.title || document.title; + main.replaceChildren(...Array.from(nextMain.childNodes)); + main.classList.remove("is-soft-loading"); + main.classList.add("is-soft-loaded"); + window.setTimeout(() => main.classList.remove("is-soft-loaded"), 180); + updateActiveNavigation(new URL(url, window.location.href).pathname); + if (push) history.pushState({}, "", url); + init(main); + executePageScripts(main); + window.scrollTo({ top: 0, behavior: "auto" }); + } catch { + window.location.assign(url); + } + } + + function updateActiveNavigation(pathname) { + document.querySelectorAll(".nav-link").forEach((link) => { + const url = new URL(link.href, window.location.href); + link.classList.toggle("active", url.pathname === pathname || (url.pathname !== "/" && pathname.startsWith(`${url.pathname}/`))); + }); + } + + function executePageScripts(root) { + root.querySelectorAll("script").forEach((script) => { + const next = document.createElement("script"); + for (const attr of script.attributes) next.setAttribute(attr.name, attr.value); + next.textContent = script.textContent; + script.replaceWith(next); + }); + } + + window.addEventListener("popstate", () => softNavigate(window.location.href, false)); + window.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt }; + document.addEventListener("DOMContentLoaded", () => { + init(document); + connectEvents(); + }); +})(); diff --git a/src/web/public/lumi-tokens.css b/src/web/public/lumi-tokens.css index e7661b7..3ac0233 100644 --- a/src/web/public/lumi-tokens.css +++ b/src/web/public/lumi-tokens.css @@ -28,6 +28,13 @@ --lumi-button-text: #ffffff; --lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black); --lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white); + --lumi-color-unsaved-bg: color-mix(in srgb, var(--lumi-warning) 13%, var(--lumi-surface)); + --lumi-color-unsaved-border: color-mix(in srgb, var(--lumi-warning) 45%, var(--lumi-border)); + --lumi-color-unsaved-text: var(--lumi-text); + --lumi-color-unsaved-ring: color-mix(in srgb, var(--lumi-warning) 26%, transparent); + --lumi-savebar-bg: color-mix(in srgb, var(--lumi-surface) 94%, transparent); + --lumi-savebar-border: var(--lumi-color-unsaved-border); + --lumi-savebar-shadow: var(--lumi-shadow-md); --lumi-space-scale: 1; --lumi-space-1: calc(0.25rem * var(--lumi-space-scale)); diff --git a/src/web/server.js b/src/web/server.js index 7b354df..4cb1eb9 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -93,6 +93,10 @@ const { consumeConfirmation, normalizeAction } = require("../services/destructive-confirm"); +const { + publishWebEvent, + subscribeWebEvents +} = require("../services/web-events"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -1514,6 +1518,113 @@ function getThemeSettings() { return getActiveTheme(); } +function parseJsonSetting(key, fallback) { + const value = getSetting(key, fallback); + if (Array.isArray(value)) return value; + if (typeof value === "string" && value.trim()) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } + } + return fallback; +} + +function safeExternalUrl(value) { + try { + const url = new URL(String(value || "")); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : ""; + } catch { + return ""; + } +} + +function permissionAllows(user, permission = "public") { + const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public"; + return role === "public" ? true : hasAccess(user, role); +} + +function fallbackIconForUrl(url) { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + return host.slice(0, 1).toUpperCase(); + } catch { + return "↗"; + } +} + +function homepageLinksForUser(user) { + return parseJsonSetting("homepage_link_buttons", []) + .filter((item) => item && item.enabled !== false) + .filter((item) => permissionAllows(user, item.permission)) + .map((item, index) => { + const url = safeExternalUrl(item.url); + if (!url) return null; + return { + id: String(item.id || `link-${index}`), + label: String(item.label || item.description || "External link").slice(0, 80), + description: String(item.description || item.label || "Open link").slice(0, 160), + url, + icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url), + fallback_icon: fallbackIconForUrl(url), + permission: item.permission || "public", + sort_order: Number(item.sort_order) || index + }; + }) + .filter(Boolean) + .sort((a, b) => a.sort_order - b.sort_order); +} + +function homepageHeroForUser(user) { + const entries = parseJsonSetting("homepage_hero_entries", []) + .filter((item) => item && item.enabled !== false) + .filter((item) => permissionAllows(user, item.permission)) + .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0)); + for (const item of entries) { + const hero = normalizeHomepageHero(item); + if (hero?.available) return hero; + } + return null; +} + +function normalizeHomepageHero(item) { + const type = String(item.type || "none"); + if (type === "none") { + return item.fallback_behavior === "message" + ? { type, available: true, title: item.title || "No featured content", description: item.description || "" } + : null; + } + const sourceUrl = safeExternalUrl(item.source_url); + const embedUrl = safeExternalUrl(item.embed_url); + const imageUrl = safeExternalUrl(item.image_url); + const title = String(item.title || "Featured content").slice(0, 120); + const description = String(item.description || "").slice(0, 500); + if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl }; + if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl }; + if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl }; + if (type === "youtube_video") { + const videoId = item.video_id || youtubeVideoId(sourceUrl); + if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl }; + } + if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) { + if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null; + return { type, available: true, title, description, embed_url: embedUrl, source_url: sourceUrl }; + } + return null; +} + +function youtubeVideoId(value) { + try { + const url = new URL(value || ""); + if (url.hostname.includes("youtu.be")) return url.pathname.slice(1); + return url.searchParams.get("v") || ""; + } catch { + return ""; + } +} + function getDiscordSettings() { return { discord_client_id: getSetting("discord_client_id", ""), @@ -1942,10 +2053,12 @@ function createWebServer({ loadPlugins, discordClient }) { assistantPanels.splice(index, 1); } }; - } + }, + emitEvent: publishWebEvent }; app.use(requireConfigured); + app.get("/api/events", requireAuth, subscribeWebEvents); app.post("/api/destructive-confirmations", requireAuth, (req, res) => { try { res.json(issueConfirmation(req, req.body.action)); @@ -2072,7 +2185,9 @@ function createWebServer({ loadPlugins, discordClient }) { app.get("/", (req, res) => { res.render("home", { - title: "Home" + title: "Home", + homepageLinks: homepageLinksForUser(req.session.user), + homepageHero: homepageHeroForUser(req.session.user) }); }); @@ -3601,6 +3716,22 @@ function createWebServer({ loadPlugins, discordClient }) { setSetting("localhost_login_password", localhostPassword); } } + for (const field of ["homepage_link_buttons", "homepage_hero_entries"]) { + if (req.body[field] === undefined) continue; + const raw = String(req.body[field] || "").trim(); + if (!raw) { + setSetting(field, []); + continue; + } + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error("Expected an array."); + setSetting(field, parsed); + } catch (error) { + setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`); + return res.redirect("/admin/settings"); + } + } const platformStatus = getPlatformStatus(); const nextPlatformValues = new Map(); for (const platform of platformStatus) { diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index e5f16d0..13beaae 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -5,7 +5,7 @@ pageTitle: "Settings", description: "Manage core behavior, updates, and platform integrations." }) %> -
+
@@ -113,6 +113,21 @@
<% } %> +
+

Homepage content

+

Configure public homepage link buttons and the priority-based dynamic hero. Use JSON arrays; invalid JSON is rejected without saving.

+
+
+ + +

Fields: enabled, label, description, url, icon_url, permission public/user/mod/admin, sort_order.

+
+
+ + +

Fields: enabled, type, title, description, priority, permission, source_url, image_url, embed_url, video_id, availability_mode, autoplay_mode, duration_seconds.

+
+
diff --git a/src/web/views/home.ejs b/src/web/views/home.ejs index eb0f8b0..21cf90a 100644 --- a/src/web/views/home.ejs +++ b/src/web/views/home.ejs @@ -7,6 +7,35 @@ Leaderboards
+<% if (homepageHero) { %> +
+
+ <%= homepageHero.type.replaceAll("_", " ") %> +

<%= homepageHero.title %>

+ <% if (homepageHero.description) { %>

<%= homepageHero.description %>

<% } %> + <% if (homepageHero.source_url) { %>Open featured content<% } %> +
+ <% if (homepageHero.image_url) { %> + + <% } else if (homepageHero.embed_url) { %> + + <% } %> +
+<% } %> +<% if ((homepageLinks || []).length) { %> + +<% } %>

Bot control

diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index b4e1b71..bfca27e 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -26,6 +26,7 @@
+ From b3a499536fd5195bfe2dee84860256fa4b50fa97 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 08:20:38 +0200 Subject: [PATCH 06/10] ui: complete webui corrective pass --- docs/lumi-ui.md | 75 ++++--- plugins/lumi_ai/backend/feedback.js | 6 +- plugins/lumi_ai/index.js | 75 +++++-- plugins/lumi_ai/public/assistant.js | 4 +- plugins/lumi_ai/public/settings.js | 57 +++++- plugins/lumi_ai/views/improvement-center.ejs | 2 +- plugins/lumi_ai/views/settings.ejs | 93 +++++++-- src/web/public/app.js | 37 +++- src/web/public/dashboard.js | 82 ++++++++ src/web/public/homepage-builder.js | 203 +++++++++++++++++++ src/web/public/lumi-components.css | 157 +++++++++++++- src/web/public/lumi-state-button.js | 2 +- src/web/public/theme-editor.css | 131 +++++++++--- src/web/public/theme-editor.js | 143 +++++++++---- src/web/server.js | 39 ++++ src/web/views/admin-dashboard.ejs | 28 +++ src/web/views/admin-logs.ejs | 28 ++- src/web/views/admin-plugins.ejs | 4 +- src/web/views/admin-settings.ejs | 53 +++-- src/web/views/admin-theme.ejs | 39 +++- src/web/views/admin-updates.ejs | 24 +-- src/web/views/partials/state-button.ejs | 2 +- 22 files changed, 1088 insertions(+), 196 deletions(-) create mode 100644 src/web/public/dashboard.js create mode 100644 src/web/public/homepage-builder.js diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index 5a657c3..b9b432b 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -81,10 +81,17 @@ presets plus bounded base-size, heading-scale, and control-density ranges. The server accepts only six-digit hex colors, supported font presets, bounded metric values, and readable text/button/input contrast. -The live preview updates colors, role colors, metrics, and typography before -save. The editor also shows contrast warnings for the current preview mode, -offers a reset-to-base action for inherited custom themes, and provides an -optional desktop pop-out preview window that stays synchronized with the editor. +Draft values are isolated to preview roots. The compact preview and pop-out +preview update colors, role colors, metrics, spacing, and typography before +save, but the editor shell and live site keep the active saved theme until the +admin saves/applies the custom theme. The compact preview is hidden on narrow +phone layouts; use the Preview action to open the synchronized pop-out instead. + +The compact preview includes representative headings, pills, cards, buttons, +state buttons, inputs, toggles, alerts/statuses, badges, tables, modal samples, +dirty-state markers, and spacing samples. The pop-out uses a faithful Lumi page +shell with safe sample data so admins can test draft themes against dashboard, +settings, logs, AI, and component-like layouts without exposing real admin data. Missing or invalid stored values are replaced from the custom theme's built-in base. Existing installations with modified legacy `theme_light_*`, @@ -112,32 +119,52 @@ password field blank keeps the existing password. Lumi AI's main Selected model dropdown lists only installed/downloaded models. If the configured model is missing, the settings page shows a warning and saving -requires selecting an installed model. Main context size is a preset dropdown: -Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported -freeform context values are rejected server-side. +requires selecting an installed model. Main context, gate context, and output +token budgets use shared presets from Tiny (256) through Extra extended +(32768). Unsupported freeform values are rejected server-side. AI feedback supports `feedback_kind` values `strict_correction` and -`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where -the model called the wrong tool or failed to call an expected tool. Review, -edit, and implementation views show both the kind and tag so admins can tell -direct answer corrections from broader tool-calling or instruction guidance. +`instruction_based`; instruction-based feedback is the default because most +reviews are guidance for future replies rather than exact replacement answers. +Feedback tags include `wrong_tool_usage` for cases where the model called the +wrong tool or failed to call an expected tool. Review, edit, and implementation +views show both the kind and tag so admins can tell direct answer corrections +from broader tool-calling or instruction guidance. + +Model/runtime downloads and the combined Start/Restart runtime control use the +Lumi state button behavior. Enhanced browsers start downloads and runtime +actions through fetch, update button state/progress in place, and avoid hard +page refreshes; the underlying POST routes remain available for non-JavaScript +fallbacks. ## Homepage Content -Admins can define homepage external link buttons in `homepage_link_buttons` from -Admin > Settings. Each entry may include `enabled`, `label`, `description`, -`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and -`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are -filtered server-side by permission. +Admins configure homepage external link buttons from Admin > Settings with the +Homepage content builder. It writes the existing `homepage_link_buttons` JSON +setting behind the scenes. Each entry may include `enabled`, `label`, +`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`, +`admin`), and `sort_order`. Links open in a new tab with +`rel="noopener noreferrer"` and are filtered server-side by permission. -Admins can define priority-based hero entries in `homepage_hero_entries`. -Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`, -`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and -`none`. The homepage renders the first enabled, available entry the current user -can access. Hero entries support priority/order, permission, source/embed/image -URLs, video IDs, availability mode, autoplay mode metadata, and duration fields. -Slow external availability checks are intentionally avoided; entries fail -closed if required local configuration is missing. +Admins configure priority-based hero entries with the same builder; it writes +the existing `homepage_hero_entries` JSON setting behind the scenes. The +homepage renders the first enabled, available entry the current user can access. +Hero entries support type, priority/order, permission, source/embed/image URLs, +video IDs, availability mode, autoplay mode metadata, and duration fields. Slow +external availability checks are intentionally avoided; entries fail closed if +required local configuration is missing. + +## Admin Dashboard And Logs + +The admin dashboard polls `GET /api/admin/dashboard-metrics` for process +uptime, memory, plugin counts, content counts, and recent log severity totals. +The dashboard renders lightweight SVG graphs using Lumi tokens and does not add +a frontend framework dependency. + +The logs page keeps server-side range/severity/limit filters and adds a labeled +responsive filter bar with search, reset, refresh, and download actions. Search +filters the loaded entries client-side; changing range, severity, or limit +reloads the same `/admin/logs` route with query parameters. ## Visual references diff --git a/plugins/lumi_ai/backend/feedback.js b/plugins/lumi_ai/backend/feedback.js index 755f3cd..7cf22e4 100644 --- a/plugins/lumi_ai/backend/feedback.js +++ b/plugins/lumi_ai/backend/feedback.js @@ -16,7 +16,7 @@ const FEEDBACK_TAGS = Object.freeze([ "wrong_tool_usage" ]); -const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]); +const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]); class FeedbackStore { constructor(options = {}) { @@ -39,7 +39,7 @@ class FeedbackStore { feedback_tag: tag, feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) ? input.feedback_kind - : "strict_correction", + : "instruction_based", optional_correction: clean(input.optional_correction, 16000), status: "pending", submitted_by: String(actor?.id || "anonymous"), @@ -76,7 +76,7 @@ class FeedbackStore { return this.mutate(id, (entry) => ({ ...entry, feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag, - feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction", + feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "instruction_based", optional_correction: clean(values.optional_correction, 16000), review_notes: clean(values.review_notes, 4000), reviewed_by: String(actor.id), diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 3b93515..90d4f24 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -39,12 +39,18 @@ const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); const PLUGIN_ID = "lumi_ai"; -const CONTEXT_OPTIONS = Object.freeze([ - { label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." }, +const TOKEN_PRESETS = Object.freeze([ + { label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." }, + { label: "Very small (512)", value: 512, description: "Short replies and low memory usage." }, + { label: "Small (1024)", value: 1024, description: "Compact answers and lightweight request gates." }, + { label: "Short (2048)", value: 2048, description: "Short conversations and normal commands." }, { label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." }, - { label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." }, - { label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." } + { label: "Large (8192)", value: 8192, description: "Longer conversations and documents." }, + { label: "Extended (16384)", value: 16384, description: "Long context when the selected model supports it." }, + { label: "Extra extended (32768)", value: 32768, description: "Highest supported local preset for large-context models." } ]); +const CONTEXT_OPTIONS = TOKEN_PRESETS; +const GATE_CONTEXT_OPTIONS = TOKEN_PRESETS.filter((option) => option.value >= 512 && option.value <= 4096); const modelManifest = require("./models_manifest.json"); const runtimeManifest = require("./runtime_manifest.json"); @@ -322,6 +328,8 @@ module.exports = { installedModels, selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id), contextOptions: CONTEXT_OPTIONS, + tokenPresets: TOKEN_PRESETS, + gateContextOptions: GATE_CONTEXT_OPTIONS, runtimeTarget, runtimeManifest, runtimeStatus, @@ -371,8 +379,23 @@ module.exports = { return flash(req, res, "error", "Choose a supported AI context size."); } const contextSize = requestedContext; + const tokenValues = TOKEN_PRESETS.map((option) => option.value); + const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value); + const requestedGateContext = Number(req.body.gate_context_size); + if (!gateContextValues.includes(requestedGateContext)) { + return flash(req, res, "error", "Choose a supported gate context size."); + } + const presetToken = (field, fallback, label) => { + const value = Number(req.body[field]); + if (!tokenValues.includes(value)) { + throw new Error(`Choose a supported preset for ${label}.`); + } + return value || fallback; + }; const previousConfig = config; - config = saveConfig({ + let nextConfig; + try { + nextConfig = { ...config, enabled: req.body.enabled === "on", selected_model_id: model.id, @@ -385,13 +408,13 @@ module.exports = { request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000), hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), - max_output_tokens: boundedInt(req.body.max_output_tokens, 64, 32768, 2048), + max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"), output_budgets: { - navigation_help: boundedInt(req.body.output_budget_navigation_help, 64, 32768, 256), - simple_answer: boundedInt(req.body.output_budget_simple_answer, 64, 32768, 512), - code_custom_command: boundedInt(req.body.output_budget_code_custom_command, 64, 32768, 896), - admin_debug: boundedInt(req.body.output_budget_admin_debug, 64, 32768, 1280), - explicit_long: boundedInt(req.body.output_budget_explicit_long, 64, 32768, 2048) + navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"), + simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"), + code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"), + admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"), + explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens") }, batch_size: boundedInt(req.body.batch_size, 32, 4096, 512), ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128), @@ -443,7 +466,7 @@ module.exports = { gate: { ...config.gate, model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id, - context_size: boundedInt(req.body.gate_context_size, 512, 4096, 1024), + context_size: requestedGateContext, threads: boundedInt(req.body.gate_threads, 1, 16, 2), timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000), high_confidence_threshold: boundedNumber(req.body.gate_high_confidence_threshold, 0.5, 0.99, 0.88), @@ -489,7 +512,11 @@ module.exports = { trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers), corrections_enabled: req.body.corrections_enabled === "on" } - }); + }; + } catch (error) { + return flash(req, res, "error", error.message); + } + config = saveConfig(nextConfig); registerAssistantCommands({ commandRouter, provider, @@ -525,11 +552,15 @@ module.exports = { router.post("/download/runtime", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); + const wantsJson = req.accepts(["json", "html"]) === "json"; const hardware = detectHardware(modelManifest.models, runtimeManifest); const target = getRuntimeTarget(hardware); - if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); + if (!target) { + if (wantsJson) return res.status(400).json({ error: "No managed llama.cpp runtime is available for this platform." }); + return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); + } try { - downloads.start({ + const job = downloads.start({ id: "runtime", ...target, kind: "runtime", @@ -540,29 +571,37 @@ module.exports = { target: target.filename } }); + if (wantsJson) return res.json({ success: true, job }); return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`); } catch (error) { + if (wantsJson) return res.status(400).json({ error: error.message }); return flash(req, res, "error", error.message); } }); router.post("/download/model/:id", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); + const wantsJson = req.accepts(["json", "html"]) === "json"; const model = getModel(req.params.id); - if (!model) return flash(req, res, "error", "Unknown model."); + if (!model) { + if (wantsJson) return res.status(404).json({ error: "Unknown model." }); + return flash(req, res, "error", "Unknown model."); + } if ( (model.id === config.selected_model_id && runtime.status().state === "running") || (model.id === config.gate.model_id && gateRuntime.status().state === "running") ) { + if (wantsJson) return res.status(400).json({ error: "Stop the AI runtimes before replacing an active model." }); return flash(req, res, "error", "Stop the AI runtimes before replacing an active model."); } const hardware = detectHardware(modelManifest.models); const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb; if (incompatible && req.body.override_compatibility !== "on") { + if (wantsJson) return res.status(400).json({ error: "This model exceeds detected RAM or free disk. Check override to download anyway." }); return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway."); } try { - downloads.start({ + const job = downloads.start({ id: `model:${model.id}`, url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`, filename: model.filename, @@ -570,8 +609,10 @@ module.exports = { size: model.size, kind: "model" }); + if (wantsJson) return res.json({ success: true, job }); return flash(req, res, "success", `${model.label} download started.`); } catch (error) { + if (wantsJson) return res.status(400).json({ error: error.message }); return flash(req, res, "error", error.message); } }); diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js index 2219241..f24e2b2 100644 --- a/plugins/lumi_ai/public/assistant.js +++ b/plugins/lumi_ai/public/assistant.js @@ -300,8 +300,8 @@ const kind = document.createElement("select"); kind.setAttribute("aria-label", "Feedback type"); for (const [value, label] of [ - ["strict_correction", "Strict correction"], - ["instruction_based", "Instruction-based guidance"] + ["instruction_based", "Instruction-based guidance"], + ["strict_correction", "Strict correction"] ]) { const option = document.createElement("option"); option.value = value; diff --git a/plugins/lumi_ai/public/settings.js b/plugins/lumi_ai/public/settings.js index 01e520b..6e41732 100644 --- a/plugins/lumi_ai/public/settings.js +++ b/plugins/lumi_ai/public/settings.js @@ -7,27 +7,66 @@ const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]"); const gpuControl = document.querySelector("[data-gpu-control]"); const accessForm = document.querySelector("[data-ai-access-form]"); + const runtimePrimary = document.querySelector("[data-runtime-primary]"); if (actions) { + const syncPrimary = (nextState) => { + if (!runtimePrimary || !window.LumiStateButton) return; + const normalized = String(nextState || state?.textContent || "").toLowerCase(); + window.LumiStateButton.setState(runtimePrimary, normalized === "running" ? "running" : "idle"); + }; actions.addEventListener("click", async (event) => { - const button = event.target.closest("[data-runtime-action]"); + const primaryButton = event.target.closest("[data-runtime-primary]"); + const button = primaryButton || event.target.closest("[data-runtime-action]"); if (!button) return; + const action = primaryButton + ? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start") + : button.dataset.runtimeAction; + if (!action) return; button.disabled = true; + if (primaryButton && window.LumiStateButton) { + window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true }); + } try { - const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" }); + const response = await fetch(`/plugins/lumi_ai/runtime/${action}`, { method: "POST" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Runtime action failed."); if (data.state) state.textContent = data.state; - if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(button.dataset.runtimeAction)) { + syncPrimary(data.state); + if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(action)) { const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed.", "verify-gate-model": "Gate model verification passed." }; - window.alert(labels[button.dataset.runtimeAction]); + window.alert(labels[action]); } } catch (error) { + if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button); window.alert(error.message); } finally { button.disabled = false; } }); } + document.querySelectorAll("[data-ai-download-form]").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = form.querySelector("[data-ai-download-button]"); + window.LumiStateButton?.setState(button, "loading", { busy: true }); + try { + const response = await fetch(form.action, { + method: "POST", + headers: { "Accept": "application/json" }, + body: new FormData(form) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Download failed to start."); + pollDownloads(); + } catch (error) { + window.LumiStateButton?.error(button); + if (downloadStatus) { + downloadStatus.hidden = false; + downloadStatus.textContent = error.message; + } + } + }); + }); const pollDownloads = async () => { if (!downloadStatus) return; try { @@ -41,9 +80,19 @@ const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`; }).join(" | "); + jobs.forEach((job) => { + const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`); + if (!button || !window.LumiStateButton) return; + if (job.state === "complete") window.LumiStateButton.setState(button, "success"); + else if (job.state === "error") window.LumiStateButton.setState(button, "error"); + else window.LumiStateButton.setState(button, "loading", { busy: true }); + const label = button.querySelector('[data-state-view="loading"] span:last-child'); + if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`; + }); if (active.length) window.setTimeout(pollDownloads, 1000); } catch {} }; + pollDownloads(); if (testForm && testOutput) { const updateTestToolsNotice = () => { if (!testToolsNotice) return; diff --git a/plugins/lumi_ai/views/improvement-center.ejs b/plugins/lumi_ai/views/improvement-center.ejs index bbe0e39..769528a 100644 --- a/plugins/lumi_ai/views/improvement-center.ejs +++ b/plugins/lumi_ai/views/improvement-center.ejs @@ -93,7 +93,7 @@
-
+
diff --git a/plugins/lumi_ai/views/settings.ejs b/plugins/lumi_ai/views/settings.ejs index 13c1501..80835f1 100644 --- a/plugins/lumi_ai/views/settings.ejs +++ b/plugins/lumi_ai/views/settings.ejs @@ -1,5 +1,17 @@ <%- include("../../../src/web/views/partials/layout-top", { title }) %> +<% + const renderPresetOptions = (options, current) => { + const value = Number(current); + const hasValue = options.some((option) => option.value === value); + let html = ""; + if (!hasValue && Number.isFinite(value)) { + html += ``; + } + html += options.map((option) => ``).join(""); + return html; + }; +%>
@@ -70,19 +82,39 @@ -
- + + <%- include("../../../src/web/views/partials/state-button", { + type: "submit", + classes: "subtle", + attrs: `data-ai-download-button data-download-id="model:${model.id}"`, + states: [ + { id: "idle", text: "Redownload" }, + { id: "loading", text: "Downloading", spinner: true }, + { id: "success", text: "Downloaded" }, + { id: "error", text: "Retry" } + ] + }) %>
-
+
<% } else { %> -
+ <% if (!model.compatible) { %> <% } %> - + <%- include("../../../src/web/views/partials/state-button", { + type: "submit", + classes: "subtle", + attrs: `data-ai-download-button data-download-id="model:${model.id}"`, + states: [ + { id: "idle", text: "Download" }, + { id: "loading", text: "Downloading", spinner: true }, + { id: "success", text: "Downloaded" }, + { id: "error", text: "Retry" } + ] + }) %>
<% } %>
@@ -95,12 +127,25 @@

Runtime

Official llama.cpp release, bound to localhost and stored inside this plugin.

- + <%- include("../../../src/web/views/partials/state-button", { + type: "button", + attrs: "data-runtime-primary", + loadingState: "starting", + successState: "running", + errorState: "error", + defaultState: runtimeStatus.state === "running" ? "running" : "idle", + states: [ + { id: "idle", text: "Start" }, + { id: "starting", text: "Starting", spinner: true }, + { id: "running", text: "Restart" }, + { id: "restarting", text: "Restarting", spinner: true }, + { id: "error", text: "Retry" } + ] + }) %> -
@@ -138,8 +183,18 @@ <% if (runtimeTarget) { %>

Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %>

<%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %>

-
- + + <%- include("../../../src/web/views/partials/state-button", { + type: "submit", + classes: "subtle", + attrs: "data-ai-download-button data-download-id=\"runtime\"", + states: [ + { id: "idle", text: runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" }, + { id: "loading", text: "Downloading", spinner: true }, + { id: "success", text: "Downloaded" }, + { id: "error", text: "Retry" } + ] + }) %>
<% } else { %>
No managed runtime build is available for this OS and architecture.
@@ -189,7 +244,7 @@
<%= category.replace("_", " ") %><%= formatBytes(bytes) %>
<% }) %> -
+ @@ -265,12 +320,12 @@
Shows Continue waiting controls without stopping the job.
-
Normal assistant requests use the class budgets below.
-
-
-
-
-
+
Normal assistant requests use the class budgets below.
+
+
+
+
+
@@ -285,7 +340,7 @@ Use the smallest downloaded model that can reliably return JSON classifications. -
+
Timeout or errors immediately escalate to the main model.
@@ -482,7 +537,7 @@
-
+
@@ -578,7 +633,7 @@ View Download -
+
<% }) %> diff --git a/src/web/public/app.js b/src/web/public/app.js index b05a3d4..3c1a7f9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -33,6 +33,21 @@ }); }); + document.querySelectorAll(".nav-section").forEach((section) => { + const summary = section.querySelector("summary"); + summary?.setAttribute("aria-expanded", section.open ? "true" : "false"); + section.addEventListener("toggle", () => { + summary?.setAttribute("aria-expanded", section.open ? "true" : "false"); + if (!section.open) return; + document.querySelectorAll(".nav-section[open]").forEach((other) => { + if (other !== section) { + other.open = false; + other.querySelector("summary")?.setAttribute("aria-expanded", "false"); + } + }); + }); + }); + media.addEventListener?.("change", () => { body.classList.remove("sidebar-open"); if (media.matches) { @@ -531,6 +546,18 @@ } }; + const actionCopy = (action) => { + const normalized = String(action || "").toLowerCase(); + if (normalized.includes("/delete")) return { title: "Confirm deletion", label: "Delete" }; + if (normalized.includes("/uninstall")) return { title: "Confirm uninstall", label: "Uninstall" }; + if (normalized.includes("/cleanup")) return { title: "Confirm cleanup", label: "Clean selected" }; + if (normalized.includes("/reset")) return { title: "Confirm reset", label: "Reset" }; + if (normalized.includes("/remove")) return { title: "Confirm removal", label: "Remove" }; + if (normalized.includes("/update")) return { title: "Confirm update", label: "Update" }; + if (normalized.includes("/restart")) return { title: "Confirm restart", label: "Restart" }; + return { title: "Confirm action", label: "Confirm" }; + }; + const isDestructiveForm = (form) => { if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; return String(form.method || "get").toLowerCase() === "post" && @@ -567,12 +594,14 @@ form.requestSubmit(submitter?.form === form ? submitter : undefined); }; + const confirmLabel = (form) => form.dataset.confirmLabel || actionCopy(destructiveAction(form)).label; + const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => { const state = destructiveStates.get(form) || {}; const update = () => { const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000)); button.disabled = remaining > 0; - button.textContent = remaining > 0 ? `Confirm in ${remaining}` : "Confirm"; + button.textContent = remaining > 0 ? `${confirmLabel(form)} in ${remaining}` : confirmLabel(form); if (!remaining && state.timer) { window.clearInterval(state.timer); state.timer = null; @@ -592,14 +621,15 @@ const action = destructiveAction(form); const state = { confirmed: false, inline: null, timer: null, expiryTimer: null }; destructiveStates.set(form, state); - const message = form.dataset.confirmText || "This action cannot be undone."; + const copy = actionCopy(action); + const message = form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone."; const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline"); let confirmButton; if (mode === "modal" && destructiveModal && destructiveConfirm) { if (activeDestructive?.form) resetDestructive(activeDestructive.form); activeDestructive = { form }; - destructiveTitle.textContent = form.dataset.confirmTitle || "Confirm destructive action"; + destructiveTitle.textContent = form.dataset.confirmTitle || copy.title; destructiveDescription.textContent = message; destructiveConfirm.disabled = true; destructiveConfirm.textContent = "Preparing..."; @@ -678,6 +708,7 @@ form.dataset.syntheticConfirmation = "true"; form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action"; form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone."; + form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm"; document.body.append(form); issueDestructiveConfirmation(form, null); }, true); diff --git a/src/web/public/dashboard.js b/src/web/public/dashboard.js new file mode 100644 index 0000000..c2bf28d --- /dev/null +++ b/src/web/public/dashboard.js @@ -0,0 +1,82 @@ +(() => { + const root = document.querySelector("[data-dashboard-metrics]"); + if (!root) return; + const memoryChart = root.querySelector("[data-memory-chart]"); + const logChart = root.querySelector("[data-log-chart]"); + const status = root.querySelector("[data-metrics-status]"); + const history = []; + + const bytes = (value) => { + const mb = Number(value || 0) / 1048576; + return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(0)} MB`; + }; + + const duration = (seconds) => { + const total = Number(seconds || 0); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + return hours ? `${hours}h ${minutes}m` : `${minutes}m`; + }; + + const setMetric = (name, value) => { + const target = root.querySelector(`[data-metric="${name}"]`); + if (target) target.textContent = value; + }; + + const line = (values) => { + const max = Math.max(...values, 1); + return values.map((value, index) => { + const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 280 + 10; + const y = 108 - (value / max) * 96; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + }; + + const drawMemory = () => { + if (!memoryChart) return; + const values = history.map((item) => item.memory.rss); + memoryChart.innerHTML = ``; + }; + + const drawLogs = (logs) => { + if (!logChart) return; + const entries = [["error", logs.error], ["warn", logs.warn], ["info", logs.info], ["debug", logs.debug]]; + const max = Math.max(...entries.map(([, value]) => value), 1); + logChart.innerHTML = entries.map(([label, value], index) => { + const height = Math.max(4, (value / max) * 86); + const x = 24 + index * 68; + const y = 100 - height; + return `${label}`; + }).join(""); + }; + + const refresh = async () => { + try { + const response = await fetch("/api/admin/dashboard-metrics", { cache: "no-store" }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Metrics unavailable."); + history.push(data); + while (history.length > 24) history.shift(); + setMetric("uptime", duration(data.uptime_seconds)); + setMetric("rss", bytes(data.memory.rss)); + setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`); + setMetric("plugins", `${data.plugins.enabled} / ${data.plugins.total}`); + setMetric("users", data.counts.users); + setMetric("commands", data.counts.commands); + if (status) { + status.textContent = "Live"; + status.className = "status-indicator status-success"; + } + drawMemory(); + drawLogs(data.logs); + } catch (error) { + if (status) { + status.textContent = error.message; + status.className = "status-indicator status-danger"; + } + } + }; + + refresh(); + window.setInterval(refresh, 10000); +})(); diff --git a/src/web/public/homepage-builder.js b/src/web/public/homepage-builder.js new file mode 100644 index 0000000..777ad40 --- /dev/null +++ b/src/web/public/homepage-builder.js @@ -0,0 +1,203 @@ +(() => { + const builders = document.querySelectorAll("[data-homepage-builder]"); + if (!builders.length) return; + + const permissions = ["public", "user", "mod", "admin"]; + const heroTypes = ["image", "video", "embed"]; + + const parseRows = (source) => { + try { + const parsed = JSON.parse(source.value || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }; + + const field = (label, input) => { + const wrapper = document.createElement("label"); + wrapper.className = "homepage-builder-field"; + const span = document.createElement("span"); + span.textContent = label; + wrapper.append(span, input); + return wrapper; + }; + + const textInput = (value = "", placeholder = "") => { + const input = document.createElement("input"); + input.value = value || ""; + input.placeholder = placeholder; + return input; + }; + + const numberInput = (value = 0, min = 0) => { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.value = Number.isFinite(Number(value)) ? String(value) : String(min); + return input; + }; + + const selectInput = (value, values) => { + const select = document.createElement("select"); + values.forEach((item) => { + const option = document.createElement("option"); + option.value = item; + option.textContent = item; + option.selected = item === value; + select.append(option); + }); + return select; + }; + + const checkbox = (checked = true) => { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = checked !== false; + return input; + }; + + const linkDefaults = () => ({ + enabled: true, + label: "", + description: "", + url: "", + icon_url: "", + permission: "public", + sort_order: 0 + }); + + const heroDefaults = () => ({ + enabled: true, + type: "image", + title: "", + description: "", + priority: 0, + permission: "public", + source_url: "", + image_url: "", + embed_url: "", + video_id: "", + availability_mode: "always", + autoplay_mode: "off", + duration_seconds: 0 + }); + + builders.forEach((builder) => { + const kind = builder.dataset.homepageBuilder; + const source = builder.querySelector(".homepage-json-source"); + const list = builder.querySelector(`[data-homepage-list="${kind}"]`); + const addButton = document.querySelector(`[data-homepage-add="${kind}"]`); + if (!source || !list) return; + let rows = parseRows(source); + + const sync = () => { + const next = Array.from(list.querySelectorAll("[data-homepage-row]")).map((row, index) => { + if (kind === "links") { + return { + enabled: row.querySelector("[data-field='enabled']").checked, + label: row.querySelector("[data-field='label']").value.trim(), + description: row.querySelector("[data-field='description']").value.trim(), + url: row.querySelector("[data-field='url']").value.trim(), + icon_url: row.querySelector("[data-field='icon_url']").value.trim(), + permission: row.querySelector("[data-field='permission']").value, + sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index + }; + } + return { + enabled: row.querySelector("[data-field='enabled']").checked, + type: row.querySelector("[data-field='type']").value, + title: row.querySelector("[data-field='title']").value.trim(), + description: row.querySelector("[data-field='description']").value.trim(), + priority: Number(row.querySelector("[data-field='priority']").value) || 0, + permission: row.querySelector("[data-field='permission']").value, + source_url: row.querySelector("[data-field='source_url']").value.trim(), + image_url: row.querySelector("[data-field='image_url']").value.trim(), + embed_url: row.querySelector("[data-field='embed_url']").value.trim(), + video_id: row.querySelector("[data-field='video_id']").value.trim(), + availability_mode: row.querySelector("[data-field='availability_mode']").value.trim() || "always", + autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value.trim() || "off", + duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0 + }; + }); + source.value = JSON.stringify(next, null, 2); + }; + + const addField = (row, labelText, element, name) => { + element.dataset.field = name; + row.append(field(labelText, element)); + }; + + const render = () => { + list.replaceChildren(); + rows.forEach((item, index) => { + const row = document.createElement("article"); + row.className = "homepage-builder-row"; + row.dataset.homepageRow = ""; + const header = document.createElement("div"); + header.className = "homepage-builder-row-header"; + const title = document.createElement("strong"); + title.textContent = item.label || item.title || `${kind === "links" ? "Link" : "Hero"} ${index + 1}`; + const enabled = checkbox(item.enabled); + enabled.dataset.field = "enabled"; + const enabledLabel = field("Enabled", enabled); + header.append(title, enabledLabel); + row.append(header); + + if (kind === "links") { + addField(row, "Label", textInput(item.label, "Commands"), "label"); + addField(row, "Description", textInput(item.description, "Open command list"), "description"); + addField(row, "URL", textInput(item.url, "/commands"), "url"); + addField(row, "Icon URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url"); + addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); + addField(row, "Sort order", numberInput(item.sort_order, 0), "sort_order"); + } else { + addField(row, "Type", selectInput(item.type || "image", heroTypes), "type"); + addField(row, "Title", textInput(item.title, "Featured stream"), "title"); + addField(row, "Description", textInput(item.description, "What's happening now"), "description"); + addField(row, "Priority", numberInput(item.priority, 0), "priority"); + addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); + addField(row, "Source URL", textInput(item.source_url, "https://..."), "source_url"); + addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url"); + addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url"); + addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id"); + addField(row, "Availability", textInput(item.availability_mode || "always"), "availability_mode"); + addField(row, "Autoplay", textInput(item.autoplay_mode || "off"), "autoplay_mode"); + addField(row, "Duration seconds", numberInput(item.duration_seconds, 0), "duration_seconds"); + } + + const actions = document.createElement("div"); + actions.className = "homepage-builder-actions"; + const duplicate = document.createElement("button"); + duplicate.type = "button"; + duplicate.className = "button subtle"; + duplicate.textContent = "Duplicate"; + duplicate.addEventListener("click", () => { + rows.splice(index + 1, 0, { ...rows[index] }); + render(); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "button danger"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => { + rows.splice(index, 1); + render(); + }); + actions.append(duplicate, remove); + row.append(actions); + row.addEventListener("input", sync); + row.addEventListener("change", sync); + list.append(row); + }); + sync(); + }; + + addButton?.addEventListener("click", () => { + rows.push(kind === "links" ? linkDefaults() : heroDefaults()); + render(); + }); + + render(); + }); +})(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 2e72ab0..b14494e 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -182,6 +182,8 @@ section.card:has(> table.table) { button.button, input[type="submit"].button { min-height: var(--lumi-control-height); + width: max-content; + max-width: 100%; display: inline-flex; align-items: center; justify-content: center; @@ -246,6 +248,7 @@ button:disabled { .lumi-state-btn { position: relative; + width: max-content; } .lumi-state-btn[aria-busy="true"] { @@ -253,6 +256,8 @@ button:disabled { } .lumi-state-btn-content { + width: max-content; + max-width: 100%; display: grid; grid-template-areas: "stack"; align-items: center; @@ -265,10 +270,21 @@ button:disabled { align-items: center; justify-content: center; gap: var(--lumi-space-2); + white-space: nowrap; } -.lumi-state-btn [data-state-view][hidden] { - display: none !important; +.lumi-state-btn [data-state-view][data-state-hidden="true"] { + visibility: hidden; + pointer-events: none; +} + +.lumi-state-btn [data-state-view][data-state-hidden="false"] { + visibility: visible; +} + +.button.full, +.lumi-state-btn.full { + width: 100%; } .lumi-state-btn-spinner { @@ -317,6 +333,18 @@ button:disabled { gap: var(--lumi-space-2); } +.input-action-row { + display: grid; + grid-template-columns: minmax(14rem, 1fr) auto; + align-items: center; + gap: var(--lumi-space-2); +} + +.input-action-row input[type="file"], +.input-action-row input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]) { + min-width: 0; +} + .field > label:first-child, fieldset > legend { color: var(--lumi-text); @@ -610,6 +638,123 @@ input[type="color"] { background: var(--lumi-surface-subtle); } +.homepage-json-source { + display: none; +} + +.homepage-builder { + display: grid; + gap: var(--lumi-space-3); +} + +.homepage-builder-list { + display: grid; + gap: var(--lumi-space-3); +} + +.homepage-builder-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.homepage-builder-row-header, +.homepage-builder-actions { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.homepage-builder-field { + display: grid; + gap: var(--lumi-space-1); + font-weight: 700; +} + +.homepage-builder-field:has(input[type="checkbox"]) { + display: inline-flex; + align-items: center; + gap: var(--lumi-space-2); +} + +.homepage-builder-field span { + color: var(--lumi-text-muted); + font-size: 0.85rem; +} + +.dashboard-metric-grid, +.dashboard-chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--lumi-space-3); +} + +.dashboard-metric-grid { + margin-top: var(--lumi-space-4); +} + +.dashboard-metric-grid > div, +.dashboard-chart-card { + min-width: 0; + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.dashboard-metric-grid span, +.dashboard-chart-card figcaption { + display: block; + color: var(--lumi-text-muted); + font-size: 0.85rem; + font-weight: 700; +} + +.dashboard-metric-grid strong { + display: block; + margin-top: var(--lumi-space-1); + font-size: 1.35rem; +} + +.dashboard-chart-grid { + margin-top: var(--lumi-space-4); +} + +.dashboard-chart-card { + margin: 0; +} + +.dashboard-chart-card svg { + width: 100%; + min-height: 9rem; + margin-top: var(--lumi-space-2); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.log-controls label { + display: grid; + gap: var(--lumi-space-1); + min-width: 9rem; +} + +.log-controls label:first-child { + min-width: min(18rem, 100%); +} + +.log-controls label > span { + color: var(--lumi-text-muted); + font-size: 0.8rem; + font-weight: 700; +} + .modal-backdrop { padding: var(--lumi-space-4); background: rgba(5, 10, 12, 0.62); @@ -824,6 +969,14 @@ details > summary { width: 100%; } + .input-action-row { + grid-template-columns: 1fr; + } + + .homepage-builder-row { + grid-template-columns: 1fr; + } + .list li { align-items: flex-start; flex-direction: column; diff --git a/src/web/public/lumi-state-button.js b/src/web/public/lumi-state-button.js index 6c4a0e9..5f0890a 100644 --- a/src/web/public/lumi-state-button.js +++ b/src/web/public/lumi-state-button.js @@ -14,7 +14,7 @@ getViews(button).forEach((view) => { const isVisible = view.dataset.stateView === nextState; - view.hidden = !isVisible; + view.dataset.stateHidden = isVisible ? "false" : "true"; view.setAttribute("aria-hidden", isVisible ? "false" : "true"); }); diff --git a/src/web/public/theme-editor.css b/src/web/public/theme-editor.css index 1ac83e9..5e50b2f 100644 --- a/src/web/public/theme-editor.css +++ b/src/web/public/theme-editor.css @@ -297,6 +297,10 @@ font-size: 0.8rem; } +.theme-mobile-preview-action { + display: none; +} + .theme-preview-window { min-height: 26rem; display: grid; @@ -326,6 +330,11 @@ background: var(--lumi-border); } +.theme-preview-nav > span.is-active { + width: 2.35rem; + background: var(--lumi-primary); +} + .theme-preview-logo { width: 2rem; height: 2rem; @@ -344,28 +353,21 @@ padding: var(--lumi-space-4); } -.theme-preview-heading { - width: 60%; - height: 1.2rem; +.theme-preview-content h2 { + margin-bottom: 0; + font-size: calc(1.25rem * var(--lumi-heading-scale)); +} + +.theme-preview-content p { + margin: 0; +} + +.theme-preview-pill { + width: max-content; + padding: calc(var(--lumi-space-1) * 0.85) var(--lumi-space-2); + border: 1px solid color-mix(in srgb, var(--lumi-primary) 30%, var(--lumi-border)); border-radius: var(--lumi-radius-pill); - background: var(--lumi-text); -} - -.theme-preview-lines { - display: grid; - gap: var(--lumi-space-2); -} - -.theme-preview-lines span { - width: 82%; - height: 0.5rem; - border-radius: var(--lumi-radius-pill); - background: var(--lumi-text-muted); - opacity: 0.45; -} - -.theme-preview-lines span:last-child { - width: 58%; + background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface)); } .theme-preview-sample-card { @@ -385,6 +387,13 @@ margin-top: var(--lumi-space-3); } +.theme-preview-sample-card .switch, +.theme-preview-badges, +.theme-preview-modal-sample, +.theme-spacing-sample { + margin-top: var(--lumi-space-3); +} + .theme-preview-statuses { display: flex; flex-wrap: wrap; @@ -394,6 +403,73 @@ font-weight: 700; } +.theme-preview-badges { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.theme-preview-badges .badge, +.theme-preview-badges .pill { + padding: 0.25rem 0.55rem; + border: 1px solid var(--lumi-border); + background: var(--lumi-surface-subtle); +} + +.theme-preview-dirty { + padding: var(--lumi-space-2); + border-radius: var(--lumi-radius-sm); + font-size: 0.85rem; + font-weight: 700; +} + +.theme-preview-table { + margin-top: var(--lumi-space-3); +} + +.theme-preview-table .table { + min-width: 16rem; + font-size: 0.78rem; +} + +.theme-preview-table th, +.theme-preview-table td { + padding: 0.45rem; +} + +.theme-preview-modal-sample { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + box-shadow: var(--lumi-shadow-sm); +} + +.theme-spacing-sample { + display: flex; + align-items: center; + gap: var(--lumi-space-2); +} + +.theme-spacing-sample span { + width: var(--lumi-space-4); + height: var(--lumi-space-4); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-primary); +} + +.theme-spacing-sample span:nth-child(2) { + width: var(--lumi-space-5); + height: var(--lumi-space-5); + background: var(--lumi-accent); +} + +.theme-spacing-sample span:nth-child(3) { + width: var(--lumi-space-6); + height: var(--lumi-space-6); + background: var(--lumi-info); +} + .is-selected { border-color: var(--lumi-primary) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important; @@ -439,18 +515,11 @@ height: 5.5rem; } - .theme-preview-window { - min-height: 16rem; - grid-template-columns: 3.25rem minmax(0, 1fr); + .theme-mobile-preview-action { + display: inline-flex; } - .theme-preview-nav, - .theme-preview-content, - .theme-preview-sample-card { - padding: var(--lumi-space-3); - } - - .theme-popout-button { + .theme-preview { display: none; } diff --git a/src/web/public/theme-editor.js b/src/web/public/theme-editor.js index 488670b..9c2deec 100644 --- a/src/web/public/theme-editor.js +++ b/src/web/public/theme-editor.js @@ -3,8 +3,7 @@ const form = editor?.querySelector("[data-theme-form]"); if (!editor || !form) return; - const root = document.documentElement; - const originalScheme = root.dataset.colorScheme || ""; + const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]")); const tokenVariables = { bg1: "--bg-1", bg2: "--bg-2", @@ -61,31 +60,45 @@ }); }; - const applyPreview = () => { - root.dataset.colorScheme = previewMode; + const buildPreviewVariables = () => { + const variables = []; form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => { const variable = tokenVariables[input.dataset.themeToken]; - if (variable) root.style.setProperty(variable, input.value); + if (variable) variables.push([variable, input.value]); }); form.querySelectorAll("[data-theme-role]").forEach((input) => { - root.style.setProperty(`--role-${input.dataset.themeRole}`, input.value); + variables.push([`--role-${input.dataset.themeRole}`, input.value]); }); form.querySelectorAll("[data-theme-metric]").forEach((input) => { const config = metricVariables[input.dataset.themeMetric]; - if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`); + if (config) variables.push([config[0], `${input.value}${config[1]}`]); }); form.querySelectorAll("[data-theme-font]").forEach((select) => { const variable = typographyVariables[select.dataset.themeFont]; const stack = select.selectedOptions[0]?.dataset.fontStack; - if (variable && stack) root.style.setProperty(variable, stack); + if (variable && stack) variables.push([variable, stack]); }); form.querySelectorAll("[data-theme-typography]").forEach((input) => { const config = typographyVariables[input.dataset.themeTypography]; - if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`); + if (config) variables.push([config[0], `${input.value}${config[1]}`]); }); + return variables; + }; + + const applyVariables = (target, variables) => { + if (!target) return; + target.dataset.colorScheme = previewMode; + variables.forEach(([name, value]) => { + target.style.setProperty(name, value); + }); + }; + + const applyPreview = () => { + const variables = buildPreviewVariables(); + previewRoots().forEach((target) => applyVariables(target, variables)); updateOutputs(); updateWarnings(); - syncPopout(); + syncPopout(variables); }; const parseHex = (value) => { @@ -133,28 +146,87 @@ ); }; - const getPreviewMarkup = () => editor.querySelector(".theme-preview-window")?.outerHTML || ""; - - const currentPreviewVariables = () => { - const variables = []; - Object.values(tokenVariables).forEach((name) => variables.push([name, root.style.getPropertyValue(name)])); - ["--role-public", "--role-mod", "--role-admin"].forEach((name) => variables.push([name, root.style.getPropertyValue(name)])); - Object.values(metricVariables).forEach(([name]) => variables.push([name, root.style.getPropertyValue(name)])); - Object.values(typographyVariables).forEach((config) => { - const name = Array.isArray(config) ? config[0] : config; - variables.push([name, root.style.getPropertyValue(name)]); - }); - return variables.filter(([, value]) => value); - }; - - const syncPopout = () => { + const syncPopout = (variables = buildPreviewVariables()) => { if (!popout || popout.closed) return; popout.document.documentElement.dataset.colorScheme = previewMode; - currentPreviewVariables().forEach(([name, value]) => { + variables.forEach(([name, value]) => { popout.document.documentElement.style.setProperty(name, value); }); }; + const previewShell = () => ` + + + + + Lumi Theme Preview + + + + + + + + +
+ +
+
+ Faithful preview +

Community control center

+

Draft theme values are isolated to this preview until you save and apply the theme.

+
+
+
+
Messages12,480Healthy stream
+
Warnings3Needs review
+
+

Settings panel

+ + +
Unsaved marker sample
+
+
+

Logs table

+
LevelMessage
errorWebhook retry failed
infoCommand synced
+
+
+

State button

+ +

Badge Preview pill

+
+
+

Modal sample

Confirmation panels inherit theme surface, shadow, radius, and button tokens.

+
+
+
+
+ + `; + form.addEventListener("input", applyPreview); form.addEventListener("change", applyPreview); editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => { @@ -188,28 +260,11 @@ return; } popout.document.open(); - popout.document.write(` - - - - - Lumi Theme Preview - - - - - - ${getPreviewMarkup()} - `); + popout.document.write(previewShell()); popout.document.close(); if (status) status.textContent = "Pop-out preview is open and updates with this editor."; syncPopout(); }); - window.addEventListener("beforeunload", () => { - if (originalScheme) root.dataset.colorScheme = originalScheme; - else delete root.dataset.colorScheme; - }); - applyPreview(); })(); diff --git a/src/web/server.js b/src/web/server.js index 4cb1eb9..4317998 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -3675,6 +3675,45 @@ function createWebServer({ loadPlugins, discordClient }) { }); }); + app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => { + const plugins = getPlugins(); + const logs = listLogs({ limit: 500 }); + const memory = process.memoryUsage(); + const count = (table) => { + try { + return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count; + } catch { + return 0; + } + }; + res.set("Cache-Control", "no-store"); + res.json({ + uptime_seconds: Math.round(process.uptime()), + memory: { + rss: memory.rss, + heap_used: memory.heapUsed, + heap_total: memory.heapTotal + }, + plugins: { + total: plugins.length, + enabled: plugins.filter((plugin) => plugin.enabled).length + }, + counts: { + users: count("user_profiles"), + commands: count("custom_commands"), + pages: count("custom_pages"), + logs: count("logs") + }, + logs: { + error: logs.filter((entry) => entry.level === "error").length, + warn: logs.filter((entry) => entry.level === "warn").length, + info: logs.filter((entry) => entry.level === "info").length, + debug: logs.filter((entry) => entry.level === "debug").length + }, + sampled_at: Date.now() + }); + }); + app.get("/admin/settings", requireRole("admin"), (req, res) => { res.render("admin-settings", { title: "Settings", diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 2778b5c..341a71a 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -43,6 +43,33 @@
+
+
+
+

Live metrics

+

Process health, content counts, plugin status, and recent log severity.

+
+ Loading +
+
+
Uptime-
+
Memory RSS-
+
Heap used-
+
Plugins enabled-
+
Users-
+
Commands-
+
+
+
+
Memory trend
+ +
+
+
Recent logs by severity
+ +
+
+

Maintenance

@@ -57,6 +84,7 @@
+ <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-logs.ejs b/src/web/views/admin-logs.ejs index a3dada7..8bbefd1 100644 --- a/src/web/views/admin-logs.ejs +++ b/src/web/views/admin-logs.ejs @@ -7,13 +7,18 @@

Core system logs with severity, timestamps, and details.

- + + + + + Reset + Refresh
diff --git a/src/web/views/admin-plugins.ejs b/src/web/views/admin-plugins.ejs index 205040d..78bade3 100644 --- a/src/web/views/admin-plugins.ejs +++ b/src/web/views/admin-plugins.ejs @@ -47,10 +47,10 @@

Install plugin from ZIP

-
+
+
-
diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index 13beaae..834fa4c 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -115,17 +115,33 @@

Homepage content

-

Configure public homepage link buttons and the priority-based dynamic hero. Use JSON arrays; invalid JSON is rejected without saving.

+

Configure public homepage link buttons and the priority-based dynamic hero without editing raw JSON.

-
- - -

Fields: enabled, label, description, url, icon_url, permission public/user/mod/admin, sort_order.

+
+
+
+

Homepage link buttons

+

Add public or role-limited links shown as cards on the homepage.

+
+ +
+ +
-
- - -

Fields: enabled, type, title, description, priority, permission, source_url, image_url, embed_url, video_id, availability_mode, autoplay_mode, duration_seconds.

+
+
+
+

Homepage hero entries

+

The first available enabled hero by priority is shown on the homepage.

+
+ +
+ +
+
+ Advanced JSON +

The builder writes JSON into hidden fields before save. Edit only if you need a field the builder does not expose.

+
@@ -147,16 +163,19 @@
<% }) %>
-<%- include("partials/layout-bottom") %> + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-theme.ejs b/src/web/views/admin-theme.ejs index bdc14e4..e70877e 100644 --- a/src/web/views/admin-theme.ejs +++ b/src/web/views/admin-theme.ejs @@ -132,6 +132,7 @@ action="/admin/theming/custom/<%= customId %>/delete" data-confirm-title="Delete custom theme" data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected." + data-confirm-label="Delete theme" > @@ -155,6 +156,7 @@
+
@@ -313,27 +315,56 @@ Live preview -
+
- +
-
-
+ Preview pill +

Theme controlled heading

+

Typography, spacing, colors, radius, shadows, and state tokens update only inside this preview.

Community overview

Preview text, surfaces, borders, and controls before saving.

Primary Secondary + + + Download + Downloading + Downloaded + +
+
Success Warning Danger
+
+ Badge + Role admin +
+
Dirty state marker
+
+ + + +
LevelMessage
InfoCommand synced
WarnQueue growing
+
+
+ Modal sample +

Radius, border, surface, and shadow.

+
+
diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 32ab2e1..83894e5 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -24,9 +24,10 @@

Upload bot update

-
- -
+
+ + +
-
- -
-
-
+ +

Upload plugin update

-
- -
-
- -
+
+ + +
diff --git a/src/web/views/partials/state-button.ejs b/src/web/views/partials/state-button.ejs index dd4d9ec..6899225 100644 --- a/src/web/views/partials/state-button.ejs +++ b/src/web/views/partials/state-button.ejs @@ -27,7 +27,7 @@ > <% stateButtonStates.forEach((state) => { %> - > + <% if (state.spinner) { %><% } %> <%= state.text %> From fdb7aafc69aa30f513ab9c7de47039a78ce97b59 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 08:33:20 +0200 Subject: [PATCH 07/10] chore: remove codex guidelines from repo --- codex-guidelines | 183 ----------------------------------------------- 1 file changed, 183 deletions(-) delete mode 100644 codex-guidelines diff --git a/codex-guidelines b/codex-guidelines deleted file mode 100644 index eb0f347..0000000 --- a/codex-guidelines +++ /dev/null @@ -1,183 +0,0 @@ -Project: Lumi Bot (Discord + Twitch + YouTube) — WebUI-first management - -Purpose of this file -- Single source of truth for cross‑conversation context, conventions, and packaging. -- Update when project behavior changes (routes, APIs, packaging, DB schema). -- Refer back here before making changes. - -Repository layout -- Core: - - src/main.js (entry) - - src/web/server.js (WebUI + routes + wizards) - - src/services (auth, platforms, users, plugins, update-manager, etc.) - - src/web/views (EJS pages, partials/layout) - - src/web/public (styles.css, app.js) -- Plugins: plugins// (plugin.json + index.js + optional views/) -- Data: data/app.db (SQLite), snapshots, uploads (should be excluded from updates) -- Updates output: updates/ (all update zips go here) - -Platform integration (current) -- Discord, Twitch, YouTube supported; modular via src/services/platforms.js -- Wizards in /setup/*: - - /setup/discord, /setup/twitch, /setup/youtube (cancelable) -- OAuth routes in /auth/*: - - /auth/discord, /auth/twitch, /auth/youtube -- Role mapping: - - Discord roles from settings discord_admin_role_id / discord_mod_role_id (supports comma-separated) - - Twitch: broadcaster/admin/mod via tags/badges - - YouTube: chat owner/moderator flags - -WebUI key routes (core) -- / (home) -- /commands, /leaderboards, /stats, /profile -- /moderator (Mods List) -- /admin - - /admin/settings - - /admin/navigation - - /admin/theming - - /admin/privileges - - /admin/logs - - /admin/updates - - /admin/commands - - /admin/pages - - /admin/users - - /admin/plugins - -WebUI profile hook (core) -- web.addProfileSection({ id, label, view?, content?, role?, order?, locals? }) - - view: EJS include path; content: raw HTML string - - role defaults to "public" if omitted - - In profile template, sections render under "Personalized" - - Profile view receives: user, profile, accounts + section.locals - -Update system (core) -- applyBotUpdate(zipPath, { mode: "full"|"patch" }) - - Full update requires: package.json, safe-mode.js, src/main.js, src/web/server.js - - Patch mode accepts any files and overlays them (no deletes) -- applyPluginUpdate(zipPath) expects plugin.json in root + entry file -- Snapshots: data/snapshots (keeps last 20 successful) -- Safe Mode: safe-mode.js supports rollback - -Update packaging standards -- Always place update zips in updates/ -- Core full update (default): - - Zip from repo root EXCLUDING: .git, node_modules, data, plugins, updates - - Filename: updates/lumi-update-.zip -- Core patch update: - - Zip only changed files/folders - - Use Patch Mode in UI - - Filename: updates/lumi-update--patch.zip (or similar) -- Plugin update: - - Zip contents of plugins// (root = plugin folder) - - Filename: updates/lumi-plugin--vX.Y.Z.zip -- Preferred zip tool on Windows: - - tar -a -c -f -C . - -Command framework (core) -- commandRouter.registerCommands(pluginId, [{ id, triggers, platforms, handler }]) -- Platforms enumerated by services/platforms.js -- Plugins can expose cmds.json for admin command list ingestion -- Core dynamic command: !top (categories pulled from leaderboards/providers via src/services/top.js) - -Database schema (core) -- data/app.db (SQLite) -- user_profiles table includes: - - internal_username (unique, NOCASE) - - username_updated_at (added for 90‑day cooldown) -- user_identities: provider/user mapping -- plugin_settings: plugin key/value store -- plugins: plugin registry - -Profile username cooldown (core) -- User can update once every 90 days -- Stored in user_profiles.username_updated_at -- /profile/username checks cooldown server‑side -- UI: modal with disabled button + hint when on cooldown - -Sidebar UX standards (core) -- User chip is clickable to /profile -- Collapsed sidebar: - - Icons centered, tooltips on items/sections - - Subitem icons remain visible (default or admin-uploaded) - - Consistent padding/width -- Enable/disable inputs should use green/red switch toggles (not plain checkboxes) -- /admin/navigation uses drag-and-drop layout with an Advanced JSON editor - -Plugins (important) -- Plugin system loads from plugins/ directory -- Each plugin: - - plugin.json with id, name, version, main (index.js) - - index.js exports { id, init(...) } -- web.mount("/plugins/", router, navItem) to add nav entry -- Plugins should avoid core edits unless explicitly requested -- Plugins receive `webhooks` for raw-body inbound endpoint registration and - outbound webhook sending. See `docs/webhooks.md`. -- `web.addAssistantPanel({ id, view, stylesheet?, script?, role?, getAvailability?(user), version?, locals? })` - registers a dynamically mounted sidebar pill/global panel above the user footer. - `getAvailability` may be async and must return `{ available, status?, reason_code? }`. - The core polls `/api/assistant-panels`, renders HTML only for available panels, and - returns a cleanup function that plugins should call when unloading. - -Current notable plugins -- echonomy-framework: - - Currency framework only (no items/betting) - - Banking UI: /profile/banking (plugin) - - Response templates with random/weighted replies - - Community funds (renamable) - - Commands under root (default "coins") - - Activity rewards (discord/twitch messages, discord voice) are queued per user per hour and flushed as one hourly transaction note "Activity Reward" with metadata breakdown -- echonomy-games: - - Uses echonomy-framework currency - - Hot Potato / Coinflip / Mystery Box - - Per-platform enable + configurable triggers/aliases - - Admin UI at /plugins/echonomy-games - - Stats stored in echonomy_game_stats (plays, coins won/lost, last played) -- moderation: - - Global moderation actions, notes, sanctions - - Ban/timeout UI at /plugins/moderation - - TOs & Bans view at /plugins/moderation/tos-bans - - Login gating shows moderation status screen - - Evidence uploads stored in data/moderation/evidence (download via /plugins/moderation/evidence/:id) -- quotes: - - Quote storage/search with WebUI at /plugins/quotes - - quotes table fields include quoter_user_id (internal user id) + game_name - - Stats provider adds total quotes, top quoters, and top quoted games - -Database schema (core) -- mod_role_periods table tracks mod/admin time for Mods List -- auto-vc: - - Auto VC creation based on lobby channels - - Game name detection uses Discord presence PLAYING/STREAMING/COMPETING only -- expression-interaction: - - Action commands with stats tracking - -Important settings keys (core) -- discord: discord_client_id, discord_client_secret, discord_bot_token, discord_guild_id, discord_admin_role_id, discord_mod_role_id, discord_redirect_uri -- twitch: twitch_client_id, twitch_client_secret, twitch_bot_username, twitch_bot_oauth, twitch_channels, twitch_redirect_uri -- youtube: youtube_client_id, youtube_client_secret, youtube_bot_refresh_token, youtube_bot_channel_id, youtube_redirect_uri -- site: site_title, bot_avatar_url, command_prefix -- nav: nav_item_icons (map of nav item id -> filename in data/nav-icons), nav_structure (custom sidebar layout) - -Known file locations -- Layout partials: src/web/views/partials/layout-top.ejs, layout-bottom.ejs -- Lumi UI tokens: src/web/public/lumi-tokens.css -- Lumi UI layout: src/web/public/lumi-layout.css -- Lumi UI components: src/web/public/lumi-components.css -- Legacy/feature CSS: src/web/public/styles.css -- Theme service: src/services/themes.js -- UI and theme conventions: docs/lumi-ui.md -- Global JS: src/web/public/app.js - - Asset versioning: res.locals.assetVersion (cache-bust for styles/app) -- Nav icons: src/web/public/icons/nav (defaults), data/nav-icons (admin uploads) - -Packaging sanity checks (before shipping) -- Plugin zips contain plugin.json at root -- Core zip contains package.json and src/main.js -- No data/ or node_modules included -- Update zip placed in updates/ - -TODOs / Open questions -- Align plugin profile sections with new core hook (replace direct injection if used) -- Standardize plugin response templates UI to avoid duplication -- Consider centralized search API for profile user lookup -- Add docs for web.addProfileSection usage (when stable) From 64da8ae103b792a97a699514ef8a81df330117bb Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 09:19:27 +0200 Subject: [PATCH 08/10] ui: refine homepage builder and update controls --- .gitignore | 3 + docs/lumi-ui.md | 44 +++++- plugins/lumi_ai/data/runtime/.gitkeep | 1 - src/web/public/app.js | 63 ++++++-- src/web/public/homepage-builder.js | 219 ++++++++++++++++++++++---- src/web/public/lumi-components.css | 22 +++ src/web/server.js | 8 +- src/web/views/admin-dashboard.ejs | 31 +++- src/web/views/admin-plugins.ejs | 49 +++++- src/web/views/admin-settings.ejs | 54 ++++--- src/web/views/admin-updates.ejs | 45 +++++- 11 files changed, 459 insertions(+), 80 deletions(-) delete mode 100644 plugins/lumi_ai/data/runtime/.gitkeep diff --git a/.gitignore b/.gitignore index 07581c2..823709b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ npm-debug.log security-audit-*.json security-audit-*.md taskfile.txt +codex-guidelines +Twitch.png +twitch-credentials-lumi.png diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index b9b432b..d6452ea 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -52,6 +52,19 @@ tracks original values, marks changed fields with theme-aware unsaved styling, shows a top Save changes bar, warns before accidental navigation, and clears markers only after successful saves. +Buttons should use `partials/state-button.ejs` for submit, loading, success, or +error states. Single-state and multi-state buttons share the same Lumi button +tokens. Hidden states stay measurable with `data-state-hidden`, so the button +width is based on the widest state and state changes do not shift surrounding +layout. Use `.input-action-row` for desktop file/input + action pairs such as +ZIP updates and navigation icon uploads; the row stacks on mobile. + +Destructive POST forms should provide context through `data-confirm-title`, +`data-confirm-text`, and `data-confirm-label`. Dynamic JavaScript-only +destructive actions can call `window.LumiConfirm.destructive({ title, text, +label })` to reuse the same modal. The helper keeps vague default confirmation +copy out of normal admin flows and returns focus after cancel/confirm. + Expandable settings rows use `data-lumi-expandable-settings` on a `
` container. Preview text can be wired with `data-placeholder-preview="#field-id"`; known placeholders such as `{gifter_username}`, `{item_name}`, @@ -63,6 +76,10 @@ Soft navigation progressively enhances same-origin links by replacing JavaScript is unavailable, or unsaved settings are present, navigation falls back to normal browser behavior. +Sidebar navigation sections behave as an accordion. Opening one `.nav-section` +closes the other expanded sections while preserving the active page highlight +and `aria-expanded` state. + ## Themes Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High @@ -142,17 +159,21 @@ fallbacks. Admins configure homepage external link buttons from Admin > Settings with the Homepage content builder. It writes the existing `homepage_link_buttons` JSON setting behind the scenes. Each entry may include `enabled`, `label`, -`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`, -`admin`), and `sort_order`. Links open in a new tab with +`description`, `url`, `icon_mode`, `icon_url`, `fetched_favicon_url`, +`permission` (`public`, `user`, `mod`, `admin`), and `sort_order`. Entries can +be added, duplicated, moved up/down, removed with contextual confirmation, +enabled/disabled, and previewed in place. Links open in a new tab with `rel="noopener noreferrer"` and are filtered server-side by permission. Admins configure priority-based hero entries with the same builder; it writes the existing `homepage_hero_entries` JSON setting behind the scenes. The homepage renders the first enabled, available entry the current user can access. Hero entries support type, priority/order, permission, source/embed/image URLs, -video IDs, availability mode, autoplay mode metadata, and duration fields. Slow -external availability checks are intentionally avoided; entries fail closed if -required local configuration is missing. +video IDs, availability mode, mutually exclusive autoplay modes, duration +fields, fallback behavior, and a live card preview. The builder shows +video-only controls only for stream/video types and image/embed/source fields +only when they apply. Slow external availability checks are intentionally +avoided; entries fail closed if required local configuration is missing. ## Admin Dashboard And Logs @@ -166,6 +187,19 @@ responsive filter bar with search, reset, refresh, and download actions. Search filters the loaded entries client-side; changing range, severity, or limit reloads the same `/admin/logs` route with query parameters. +## Updates And Local-Only Files + +Admin update and ZIP upload controls use the same state-button and +input-action-row patterns as other Lumi actions. Git update actions have +contextual confirmation copy because they can restart the process. ZIP update +forms still submit to the existing `/admin/updates/bot` and +`/admin/updates/plugin` routes and keep the existing snapshot behavior. + +The repository ignores local-only coordination and credential artifacts such as +`codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin +runtime data stays excluded from source control; runtime folders are recreated +by the plugin data-directory initializer. + ## Visual references - [Home, desktop](screenshots/lumi-home-desktop.png) diff --git a/plugins/lumi_ai/data/runtime/.gitkeep b/plugins/lumi_ai/data/runtime/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/plugins/lumi_ai/data/runtime/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/web/public/app.js b/src/web/public/app.js index 3c1a7f9..afc5342 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -537,10 +537,12 @@ const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]"); const destructiveStates = new WeakMap(); let activeDestructive = null; + let activeCallbackConfirm = null; - const destructiveAction = (form) => { + const destructiveAction = (form, submitter = null) => { try { - return new URL(form.action, window.location.origin).pathname; + const action = submitter?.formAction || form.action; + return new URL(action, window.location.origin).pathname; } catch { return ""; } @@ -558,10 +560,10 @@ return { title: "Confirm action", label: "Confirm" }; }; - const isDestructiveForm = (form) => { + const isDestructiveForm = (form, submitter = null) => { if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; return String(form.method || "get").toLowerCase() === "post" && - destructivePattern.test(destructiveAction(form)); + destructivePattern.test(destructiveAction(form, submitter)); }; const resetDestructive = (form) => { @@ -579,6 +581,41 @@ if (form.dataset.syntheticConfirmation === "true") form.remove(); }; + const resetCallbackConfirm = (result = false) => { + const active = activeCallbackConfirm; + if (!active) return; + activeCallbackConfirm = null; + destructiveModal?.classList.remove("is-open"); + destructiveModal?.setAttribute("aria-hidden", "true"); + destructiveConfirm?.removeEventListener("click", active.onConfirm); + active.resolve(result); + active.returnFocus?.focus?.(); + }; + + window.LumiConfirm = { + destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) { + if (!destructiveModal || !destructiveConfirm) { + return Promise.resolve(window.confirm(text)); + } + if (activeDestructive?.form) resetDestructive(activeDestructive.form); + if (activeCallbackConfirm) resetCallbackConfirm(false); + return new Promise((resolve) => { + const returnFocus = document.activeElement; + destructiveTitle.textContent = title; + destructiveDescription.textContent = text; + destructiveConfirm.disabled = false; + destructiveConfirm.textContent = label; + destructiveConfirm.classList.toggle("danger", danger); + destructiveModal.classList.add("is-open"); + destructiveModal.setAttribute("aria-hidden", "false"); + const onConfirm = () => resetCallbackConfirm(true); + activeCallbackConfirm = { resolve, returnFocus, onConfirm }; + destructiveConfirm.addEventListener("click", onConfirm); + destructiveConfirm.focus(); + }); + } + }; + const submitDestructive = (form, submitter, token) => { let tokenField = form.querySelector('input[name="confirmation_token"]'); if (!tokenField) { @@ -594,14 +631,14 @@ form.requestSubmit(submitter?.form === form ? submitter : undefined); }; - const confirmLabel = (form) => form.dataset.confirmLabel || actionCopy(destructiveAction(form)).label; + const confirmLabel = (form, submitter = null) => form.dataset.confirmLabel || submitter?.dataset?.confirmLabel || actionCopy(destructiveAction(form, submitter)).label; const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => { const state = destructiveStates.get(form) || {}; const update = () => { const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000)); button.disabled = remaining > 0; - button.textContent = remaining > 0 ? `${confirmLabel(form)} in ${remaining}` : confirmLabel(form); + button.textContent = remaining > 0 ? `${confirmLabel(form, submitter)} in ${remaining}` : confirmLabel(form, submitter); if (!remaining && state.timer) { window.clearInterval(state.timer); state.timer = null; @@ -618,20 +655,21 @@ const issueDestructiveConfirmation = async (form, submitter) => { if (destructiveStates.has(form)) return; - const action = destructiveAction(form); + const action = destructiveAction(form, submitter); const state = { confirmed: false, inline: null, timer: null, expiryTimer: null }; destructiveStates.set(form, state); const copy = actionCopy(action); - const message = form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone."; + const message = submitter?.dataset?.confirmText || form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone."; const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline"); let confirmButton; if (mode === "modal" && destructiveModal && destructiveConfirm) { if (activeDestructive?.form) resetDestructive(activeDestructive.form); activeDestructive = { form }; - destructiveTitle.textContent = form.dataset.confirmTitle || copy.title; + destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title; destructiveDescription.textContent = message; destructiveConfirm.disabled = true; + destructiveConfirm.classList.add("danger"); destructiveConfirm.textContent = "Preparing..."; destructiveModal.classList.add("is-open"); destructiveModal.setAttribute("aria-hidden", "false"); @@ -683,7 +721,7 @@ document.addEventListener("submit", (event) => { const form = event.target; - if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form)) return; + if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form, event.submitter)) return; const state = destructiveStates.get(form); if (state?.confirmed) { state.confirmed = false; @@ -716,16 +754,21 @@ document.querySelectorAll("[data-destructive-cancel]").forEach((button) => { button.addEventListener("click", () => { if (activeDestructive?.form) resetDestructive(activeDestructive.form); + else resetCallbackConfirm(false); }); }); destructiveModal?.addEventListener("click", (event) => { if (event.target === destructiveModal && activeDestructive?.form) { resetDestructive(activeDestructive.form); + } else if (event.target === destructiveModal) { + resetCallbackConfirm(false); } }); window.addEventListener("keydown", (event) => { if (event.key === "Escape" && activeDestructive?.form) { resetDestructive(activeDestructive.form); + } else if (event.key === "Escape") { + resetCallbackConfirm(false); } }); diff --git a/src/web/public/homepage-builder.js b/src/web/public/homepage-builder.js index 777ad40..beb3afe 100644 --- a/src/web/public/homepage-builder.js +++ b/src/web/public/homepage-builder.js @@ -3,7 +3,28 @@ if (!builders.length) return; const permissions = ["public", "user", "mod", "admin"]; - const heroTypes = ["image", "video", "embed"]; + const heroTypes = [ + ["static_image", "Static image"], + ["custom_embed", "Custom embed"], + ["custom_link", "Custom link"], + ["youtube_video", "YouTube video"], + ["youtube_channel", "YouTube channel"], + ["twitch_stream", "Twitch stream"], + ["discord_server_overview", "Discord server overview"], + ["none", "Fallback message"] + ]; + const availabilityModes = [ + ["always", "Always available"], + ["live_only", "Only while live"], + ["scheduled", "Scheduled/manual"] + ]; + const autoplayModes = [ + ["off", "No autoplay"], + ["muted", "Autoplay muted"], + ["sound", "Autoplay with sound"] + ]; + + const heroTypeLabel = (value) => heroTypes.find(([id]) => id === value)?.[1] || value || "Hero"; const parseRows = (source) => { try { @@ -14,9 +35,10 @@ } }; - const field = (label, input) => { + const field = (label, input, options = {}) => { const wrapper = document.createElement("label"); wrapper.className = "homepage-builder-field"; + if (options.relevance) wrapper.dataset.relevance = options.relevance; const span = document.createElement("span"); span.textContent = label; wrapper.append(span, input); @@ -41,10 +63,11 @@ const selectInput = (value, values) => { const select = document.createElement("select"); values.forEach((item) => { + const [id, label] = Array.isArray(item) ? item : [item, item]; const option = document.createElement("option"); - option.value = item; - option.textContent = item; - option.selected = item === value; + option.value = id; + option.textContent = label; + option.selected = id === value; select.append(option); }); return select; @@ -62,14 +85,16 @@ label: "", description: "", url: "", + icon_mode: "favicon", icon_url: "", + fetched_favicon_url: "", permission: "public", sort_order: 0 }); const heroDefaults = () => ({ enabled: true, - type: "image", + type: "static_image", title: "", description: "", priority: 0, @@ -80,9 +105,23 @@ video_id: "", availability_mode: "always", autoplay_mode: "off", - duration_seconds: 0 + duration_seconds: 0, + fallback_behavior: "message" }); + const firstLetter = (value) => { + try { + return new URL(value || "").hostname.replace(/^www\./, "").slice(0, 1).toUpperCase() || "L"; + } catch { + return "L"; + } + }; + + const confirmation = async (options) => { + if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options); + return window.confirm(options.text); + }; + builders.forEach((builder) => { const kind = builder.dataset.homepageBuilder; const source = builder.querySelector(".homepage-json-source"); @@ -99,7 +138,9 @@ label: row.querySelector("[data-field='label']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(), url: row.querySelector("[data-field='url']").value.trim(), + icon_mode: row.querySelector("[data-field='icon_mode']").value, icon_url: row.querySelector("[data-field='icon_url']").value.trim(), + fetched_favicon_url: row.querySelector("[data-field='fetched_favicon_url']").value.trim(), permission: row.querySelector("[data-field='permission']").value, sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index }; @@ -109,23 +150,103 @@ type: row.querySelector("[data-field='type']").value, title: row.querySelector("[data-field='title']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(), - priority: Number(row.querySelector("[data-field='priority']").value) || 0, + priority: Number(row.querySelector("[data-field='priority']").value) || index, permission: row.querySelector("[data-field='permission']").value, source_url: row.querySelector("[data-field='source_url']").value.trim(), image_url: row.querySelector("[data-field='image_url']").value.trim(), embed_url: row.querySelector("[data-field='embed_url']").value.trim(), video_id: row.querySelector("[data-field='video_id']").value.trim(), - availability_mode: row.querySelector("[data-field='availability_mode']").value.trim() || "always", - autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value.trim() || "off", - duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0 + availability_mode: row.querySelector("[data-field='availability_mode']").value, + autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value, + duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0, + fallback_behavior: row.querySelector("[data-field='fallback_behavior']").value }; }); + rows = next; source.value = JSON.stringify(next, null, 2); + source.dispatchEvent(new Event("input", { bubbles: true })); + renderPreviews(); }; - const addField = (row, labelText, element, name) => { + const addField = (row, labelText, element, name, options = {}) => { element.dataset.field = name; - row.append(field(labelText, element)); + row.append(field(labelText, element, options)); + return element; + }; + + const updateHeroRelevance = (row) => { + if (kind !== "heroes") return; + const type = row.querySelector("[data-field='type']")?.value || "static_image"; + const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type); + const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type); + const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type); + const image = type === "static_image"; + const fallback = type === "none"; + row.querySelectorAll("[data-relevance]").forEach((item) => { + const relevance = item.dataset.relevance; + const visible = + relevance === "video" ? videoLike : + relevance === "embed" ? embedded : + relevance === "source" ? source : + relevance === "image" ? image : + relevance === "fallback" ? fallback : + true; + item.hidden = !visible; + }); + }; + + const updateLinkRelevance = (row) => { + if (kind !== "links") return; + const mode = row.querySelector("[data-field='icon_mode']")?.value || "favicon"; + row.querySelectorAll("[data-relevance]").forEach((item) => { + const relevance = item.dataset.relevance; + item.hidden = !( + (relevance === "manual-icon" && mode === "manual") || + (relevance === "fetched-icon" && mode === "favicon") + ); + }); + }; + + const renderPreviews = () => { + list.querySelectorAll("[data-homepage-row]").forEach((row) => { + const preview = row.querySelector("[data-homepage-preview]"); + if (!preview) return; + if (kind === "links") { + const label = row.querySelector("[data-field='label']").value.trim() || "Homepage link"; + const description = row.querySelector("[data-field='description']").value.trim() || "Open external link"; + const url = row.querySelector("[data-field='url']").value.trim(); + const iconMode = row.querySelector("[data-field='icon_mode']").value; + const iconUrl = + iconMode === "manual" ? row.querySelector("[data-field='icon_url']").value.trim() : + iconMode === "favicon" ? row.querySelector("[data-field='fetched_favicon_url']").value.trim() : + ""; + preview.innerHTML = ` + ${iconUrl ? `` : escapeHtml(firstLetter(url))} + ${escapeHtml(label)}${escapeHtml(description)} + `; + return; + } + const type = row.querySelector("[data-field='type']").value; + const title = row.querySelector("[data-field='title']").value.trim() || heroTypeLabel(type); + const description = row.querySelector("[data-field='description']").value.trim() || "Hero preview"; + preview.innerHTML = ` + ${escapeHtml(heroTypeLabel(type))} + ${escapeHtml(title)} + ${escapeHtml(description)} + `; + }); + }; + + const moveRow = (from, direction) => { + const to = from + direction; + if (to < 0 || to >= rows.length) return; + const [item] = rows.splice(from, 1); + rows.splice(to, 0, item); + rows.forEach((row, index) => { + if (kind === "links") row.sort_order = index; + else row.priority = index; + }); + render(); }; const render = () => { @@ -148,26 +269,48 @@ addField(row, "Label", textInput(item.label, "Commands"), "label"); addField(row, "Description", textInput(item.description, "Open command list"), "description"); addField(row, "URL", textInput(item.url, "/commands"), "url"); - addField(row, "Icon URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url"); + const iconMode = addField(row, "Icon mode", selectInput(item.icon_mode || "favicon", [["favicon", "Fetched favicon/logo"], ["manual", "Manual icon URL"], ["letter", "Fallback letter"]]), "icon_mode"); + addField(row, "Manual icon/logo URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon" }); + addField(row, "Fetched favicon/logo preview", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon" }); addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); - addField(row, "Sort order", numberInput(item.sort_order, 0), "sort_order"); + addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order"); + iconMode.addEventListener("change", () => updateLinkRelevance(row)); } else { - addField(row, "Type", selectInput(item.type || "image", heroTypes), "type"); + const typeSelect = addField(row, "Type", selectInput(item.type || "static_image", heroTypes), "type"); addField(row, "Title", textInput(item.title, "Featured stream"), "title"); addField(row, "Description", textInput(item.description, "What's happening now"), "description"); - addField(row, "Priority", numberInput(item.priority, 0), "priority"); + addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority"); addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); - addField(row, "Source URL", textInput(item.source_url, "https://..."), "source_url"); - addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url"); - addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url"); - addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id"); - addField(row, "Availability", textInput(item.availability_mode || "always"), "availability_mode"); - addField(row, "Autoplay", textInput(item.autoplay_mode || "off"), "autoplay_mode"); - addField(row, "Duration seconds", numberInput(item.duration_seconds, 0), "duration_seconds"); + addField(row, "Source URL or platform ID", textInput(item.source_url, "https://..."), "source_url", { relevance: "source" }); + addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url", { relevance: "image" }); + addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url", { relevance: "embed" }); + addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" }); + addField(row, "Availability mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" }); + addField(row, "Autoplay mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" }); + addField(row, "Duration timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video" }); + addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" }); + typeSelect.addEventListener("change", () => updateHeroRelevance(row)); } + const preview = document.createElement("div"); + preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero"; + preview.dataset.homepagePreview = ""; + row.append(preview); + const actions = document.createElement("div"); actions.className = "homepage-builder-actions"; + const up = document.createElement("button"); + up.type = "button"; + up.className = "button subtle"; + up.textContent = "Move up"; + up.disabled = index === 0; + up.addEventListener("click", () => moveRow(index, -1)); + const down = document.createElement("button"); + down.type = "button"; + down.className = "button subtle"; + down.textContent = "Move down"; + down.disabled = index === rows.length - 1; + down.addEventListener("click", () => moveRow(index, 1)); const duplicate = document.createElement("button"); duplicate.type = "button"; duplicate.className = "button subtle"; @@ -180,15 +323,28 @@ remove.type = "button"; remove.className = "button danger"; remove.textContent = "Remove"; - remove.addEventListener("click", () => { + remove.addEventListener("click", async () => { + const name = item.label || item.title || `${kind === "links" ? "link" : "hero"} ${index + 1}`; + const confirmed = await confirmation({ + title: kind === "links" ? "Remove homepage link" : "Remove homepage hero", + text: `Remove ${name}? This only becomes permanent after you save settings.`, + label: "Remove" + }); + if (!confirmed) return; rows.splice(index, 1); render(); }); - actions.append(duplicate, remove); + actions.append(up, down, duplicate, remove); row.append(actions); row.addEventListener("input", sync); - row.addEventListener("change", sync); + row.addEventListener("change", () => { + updateLinkRelevance(row); + updateHeroRelevance(row); + sync(); + }); list.append(row); + updateLinkRelevance(row); + updateHeroRelevance(row); }); sync(); }; @@ -200,4 +356,13 @@ render(); }); + + function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } })(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index b14494e..6e0c3e2 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -689,6 +689,28 @@ input[type="color"] { font-size: 0.85rem; } +.homepage-builder-field[hidden] { + display: none; +} + +.homepage-builder-preview { + grid-column: 1 / -1; + margin: 0; +} + +.homepage-builder-preview.hero { + min-height: 8rem; + display: grid; + align-content: center; + gap: var(--lumi-space-2); + padding: var(--lumi-space-4); +} + +.homepage-builder-preview small, +.homepage-builder-preview .hint { + color: var(--lumi-text-muted); +} + .dashboard-metric-grid, .dashboard-chart-grid { display: grid; diff --git a/src/web/server.js b/src/web/server.js index 4317998..689c1c1 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1562,12 +1562,18 @@ function homepageLinksForUser(user) { .map((item, index) => { const url = safeExternalUrl(item.url); if (!url) return null; + const iconMode = String(item.icon_mode || "").trim(); + const iconUrl = + iconMode === "manual" ? safeExternalUrl(item.icon_url) : + iconMode === "favicon" ? safeExternalUrl(item.fetched_favicon_url) : + iconMode === "letter" ? "" : + safeExternalUrl(item.icon_url || item.fetched_favicon_url); return { id: String(item.id || `link-${index}`), label: String(item.label || item.description || "External link").slice(0, 80), description: String(item.description || item.label || "Open link").slice(0, 160), url, - icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url), + icon_url: iconUrl, fallback_icon: fallbackIconForUrl(url), permission: item.permission || "public", sort_order: Number(item.sort_order) || index diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 341a71a..65a42f6 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -74,13 +74,36 @@

Maintenance

- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Check for updates" }, + { id: "loading", text: "Checking", spinner: true }, + { id: "success", text: "Checked" } + ] + }) %>
-
- + + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>
- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Restart bot" }, + { id: "loading", text: "Restarting", spinner: true }, + { id: "success", text: "Restarting" } + ] + }) %>
diff --git a/src/web/views/admin-plugins.ejs b/src/web/views/admin-plugins.ejs index 78bade3..1f20bea 100644 --- a/src/web/views/admin-plugins.ejs +++ b/src/web/views/admin-plugins.ejs @@ -28,12 +28,28 @@
- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: plugin.enabled ? "Disable" : "Enable" }, + { id: "loading", text: "Saving", spinner: true }, + { id: "success", text: "Saved" } + ] + }) %>
- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Update" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>
-
+
@@ -49,7 +65,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload plugin" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>
@@ -60,7 +83,14 @@ - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Install plugin" }, + { id: "loading", text: "Installing", spinner: true }, + { id: "success", text: "Installed" } + ] + }) %>
@@ -78,7 +108,14 @@ - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Create plugin" }, + { id: "loading", text: "Creating", spinner: true }, + { id: "success", text: "Created" } + ] + }) %>
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index 834fa4c..afb097c 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -41,23 +41,34 @@
- - - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Save settings" }, + { id: "loading", text: "Saving", spinner: true }, + { id: "success", text: "Saved" } + ] + }) %> + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"", + states: [ + { id: "idle", text: "Check for updates" }, + { id: "loading", text: "Checking", spinner: true }, + { id: "success", text: "Checked" } + ] + }) %> + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>

Git update checks use the configured remote and branch.

@@ -144,7 +155,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Save settings" }, + { id: "loading", text: "Saving", spinner: true }, + { id: "success", text: "Saved" } + ] + }) %>
diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 83894e5..8d0ce8e 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -12,12 +12,27 @@

Git updates

Check or pull updates from the remote and branch configured in Settings.

-
- -
-
- -
+
+ <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Check for updates" }, + { id: "loading", text: "Checking", spinner: true }, + { id: "success", text: "Checked" } + ] + }) %> +
+
+ <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %> +
@@ -26,7 +41,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload bot update" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>
@@ -44,7 +66,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload plugin update" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>
From c611790370321019035eb672ec42944605288635 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 09:44:16 +0200 Subject: [PATCH 09/10] updates: add version-aware recovery flow --- README.md | 13 ++ docs/lumi-ui.md | 20 +- docs/recovery-mode.md | 54 +++++ docs/updates.md | 75 +++++++ run.js | 11 +- safe-mode.js | 77 ++++++- src/main.js | 18 +- src/services/recovery-mode.js | 132 ++++++++++++ src/services/repo-update.js | 223 +++++++++++++++++++ src/services/update-index.js | 261 +++++++++++++++++++++++ src/services/update-manager.js | 99 ++++++++- src/services/versioning.js | 118 +++++++++++ src/web/public/app.js | 92 ++++++++ src/web/public/lumi-components.css | 95 +++++++++ src/web/server.js | 259 +++++++++++++++++++++- src/web/views/admin-updates.ejs | 330 +++++++++++++++++++++-------- 16 files changed, 1767 insertions(+), 110 deletions(-) create mode 100644 docs/recovery-mode.md create mode 100644 docs/updates.md create mode 100644 src/services/recovery-mode.js create mode 100644 src/services/repo-update.js create mode 100644 src/services/update-index.js create mode 100644 src/services/versioning.js diff --git a/README.md b/README.md index c3f3b98..d72e351 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ You can set these in `.env` or change role IDs in **Admin → Settings**. Use **Admin → Plugins** to install, enable, update, or uninstall plugins. You can also create a local plugin from the WebUI. +## Updates and recovery + +Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads +repo metadata from `main` by default, can explicitly target the newest +`experimental-*` branch, creates snapshots before updates, blocks unsafe major +jumps without compatibility bridge metadata, and keeps advanced ZIP updates +hidden behind manual reveal controls. + +Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`, +`node run.js --safe-mode`, or `data/recovery/safe-mode.flag`. See +[`docs/updates.md`](docs/updates.md) and +[`docs/recovery-mode.md`](docs/recovery-mode.md). + ## Twitch bot Configure Twitch chat settings in **Admin → Settings**: diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index d6452ea..a2b0190 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -189,11 +189,21 @@ reloads the same `/admin/logs` route with query parameters. ## Updates And Local-Only Files -Admin update and ZIP upload controls use the same state-button and -input-action-row patterns as other Lumi actions. Git update actions have -contextual confirmation copy because they can restart the process. ZIP update -forms still submit to the existing `/admin/updates/bot` and -`/admin/updates/plugin` routes and keep the existing snapshot behavior. +Admin update controls use the same progressive action patterns as other Lumi +actions. `/admin/updates` is split into Core and Plugins expandable sections +with collapsed summaries, warning/danger badges, safe target versions, changelog +ranges, migration notes, recovery state, snapshot/revert availability, and live +progress from `GET /admin/updates/events`. + +Core update success shows a five-second in-page notice before refresh/restart. +Plugin update success logs progress for the affected plugin row without forcing +a whole-page refresh. ZIP update controls remain available but are hidden under +advanced/manual reveal sections because ZIPs may bypass repository metadata and +compatibility checks. + +See [Update system](updates.md) and [Recovery mode](recovery-mode.md) for the +semver policy, compatibility bridge behavior, snapshots, revert limits, recovery +markers, and manual safe-mode triggers. The repository ignores local-only coordination and credential artifacts such as `codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin diff --git a/docs/recovery-mode.md b/docs/recovery-mode.md new file mode 100644 index 0000000..5e8f0d3 --- /dev/null +++ b/docs/recovery-mode.md @@ -0,0 +1,54 @@ +# Lumi Recovery Mode + +Failed updates should leave an administrator with a recovery path. Lumi writes a +recovery marker before update files are applied and keeps snapshots available for +manual revert. + +## Recovery Marker + +The marker lives at `data/recovery/update-marker.json` and records: + +- target kind and id +- from/to versions +- source branch +- update method +- rollback safety +- snapshot id when available +- status and timestamps + +Statuses include `pending`, `applying`, `verifying`, `completed`, `failed`, and +`stale`. A completed marker is cleared after a successful normal startup. If +Lumi starts and finds an incomplete marker, it marks it stale so the admin UI and +safe-mode UI can show the last attempted update. + +## Manual Safe Mode + +Safe mode can be started with any of these triggers: + +```bash +LUMI_SAFE_MODE=1 npm run run +node run.js --safe-mode +``` + +Creating `data/recovery/safe-mode.flag` also makes the wrapper start +`safe-mode.js` instead of the full bot. Safe mode loads only the minimum services +needed for recovery: config/database, auth/session, static recovery UI, +snapshots/revert, plugin disable, and restart controls. Optional plugins, +platform clients, AI runtime, scheduled jobs, and non-essential integrations are +not loaded. + +## Admin Recovery UI + +The normal **Admin > Updates** page shows a recovery banner when a marker is +present. The standalone safe-mode page shows the last attempted target, versions, +method, source branch, snapshot id, timestamp, and error. + +Admins can: + +- revert a safe snapshot, +- disable a problematic plugin, +- clear a stale marker after verifying startup, +- retry normal startup. + +Rollback is never automatic. Major-version rollback remains blocked unless the +snapshot is explicitly marked rollback safe. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..b57340c --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,75 @@ +# Lumi Updates + +Lumi updates are managed from **Admin > Updates**. The page separates Core and +Plugins into expandable sections and uses repository metadata to choose a safe +target before any files are changed. + +## Version Policy + +Core and plugin versions use `major.minor.patch`. + +- Patch: fixes only. +- Minor: non-breaking features or design changes. +- Major: potentially incompatible changes. + +The UI tracks three values separately: current version, latest available +version, and safe target version. Same-major updates can target the latest +version. Major crossings are blocked unless metadata exposes a compatibility +bridge, usually `X.0.0`, or another version marked with +`migration_kind: "compatibility_bridge"` and compatible with the current +install. + +If the latest version is unsafe, the UI shows the safe target as the actionable +version and lists why the absolute latest is not selected. + +## Metadata + +The updater looks for these files on the source branch: + +- `update-manifest.json` or `lumi.manifest.json` +- `CHANGELOG.md` or `changelog.json` +- `plugins//plugin.json` +- `plugins//CHANGELOG.md` or `changelog.json` + +Useful metadata fields include `version`, `channel`, `compatible_from`, +`migration_kind`, `migration_notes`, `rollback_safe`, `requirements`, `size`, +and changelog entries. Missing changelog data is shown as a warning. + +Stable checks read `main` by default. The newest `experimental-*` branch is used +only when explicitly selected on the updates page. + +## Snapshots And Revert + +Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under +`data/snapshots/` and records metadata in the snapshot index: + +- target kind/id +- from/to versions +- source branch +- update method +- rollback safety +- recovery marker id +- major-crossing flag + +Manual revert is available for core and individual plugins. Revert actions are +limited to the previous-version snapshot for that target. Major-version rollback +is blocked unless the snapshot or manifest explicitly marks rollback as safe. + +## ZIP Fallback + +Core and plugin ZIP updates remain available, but they are hidden under +**Show advanced ZIP update options**. ZIP updates create snapshots and recovery +markers. They may bypass repo metadata and compatibility checks unless the ZIP +contains valid manifest data, so use them as a manual fallback. + +## Progress Events + +Admin update actions publish Server-Sent Events through +`GET /admin/updates/events`. Event names include `update:checking`, +`update:metadata`, `update:snapshot`, `update:recovery_marker`, +`update:download`, `update:apply`, `update:verify`, `update:complete`, +`update:failed`, and `update:revert`. + +Core update success returns a five-second in-page notice before refresh/restart. +Plugin update success updates progress for the affected plugin action without a +whole-page refresh. diff --git a/run.js b/run.js index 010ad66..4964a52 100644 --- a/run.js +++ b/run.js @@ -6,6 +6,7 @@ const safeModeEntry = path.join(__dirname, "safe-mode.js"); const maxRestarts = Number(process.env.MAX_RESTARTS || 25); const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500); const restartCodes = new Set([10, 100]); +const safeModeFlag = path.join(__dirname, "data", "recovery", "safe-mode.flag"); let restarts = 0; let safeModeStarted = false; @@ -62,4 +63,12 @@ function startChild() { }); } -startChild(); +if ( + process.env.LUMI_SAFE_MODE === "1" || + process.argv.includes("--safe-mode") || + require("fs").existsSync(safeModeFlag) +) { + startSafeMode(); +} else { + startChild(); +} diff --git a/safe-mode.js b/safe-mode.js index 8ee7e35..aa734e5 100644 --- a/safe-mode.js +++ b/safe-mode.js @@ -14,6 +14,11 @@ const { const { getRoleFlags, hasAccess } = require("./src/services/rbac"); const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager"); const { requestRestart } = require("./src/services/updater"); +const { + safeModeStatus, + clearRecoveryMarker +} = require("./src/services/recovery-mode"); +const { setPluginEnabled } = require("./src/services/plugins"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -45,8 +50,13 @@ function renderPage(title, content) { main { padding: 24px 28px; max-width: 900px; margin: 0 auto; } .card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); } .button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; } + .button.subtle { background: #eef5f6; color: #173238; } .button.danger { background: #c24b3b; } .muted { color: #5a616a; } + .warning { border-left: 4px solid #d28a24; } + .danger-card { border-left: 4px solid #c24b3b; } + form { display: inline-block; margin: 4px 6px 4px 0; } + code { background: #f1ede7; padding: 2px 5px; border-radius: 5px; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; } @@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) { `; } +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function buildRecoveryPanel() { + const status = safeModeStatus(); + const marker = status.marker; + if (!marker) { + return `

Recovery state

No recovery marker is present.

`; + } + const pluginActions = marker.target_kind === "plugin" && marker.target_id + ? `
` + : ""; + return ` +
+

Last update marker

+

Status: ${escapeHtml(marker.status)}

+ + + + + + + + + + +
Target${escapeHtml(marker.target_kind || "unknown")} ${escapeHtml(marker.target_id || "")}
Version${escapeHtml(marker.from_version || "?")} -> ${escapeHtml(marker.to_version || "?")}
Method${escapeHtml(marker.update_method || "?")}
Source branch${escapeHtml(marker.source_branch || "?")}
Snapshot${escapeHtml(marker.snapshot_id || marker.recovery_marker_id || "pending")}
Updated${escapeHtml(marker.updated_at || marker.created_at || "?")}
Error${escapeHtml(marker.error || "")}
+
+ ${pluginActions} +
+
+
+
+ `; +} + const app = express(); const sessionStore = new BetterSqlite3Store({ client: db }); app.use( @@ -141,7 +192,7 @@ app.get("/", (req, res) => { res.send( renderPage( "Safe Mode", - `

Rollback snapshots

Use these snapshots to roll back failed updates. The server will restart after rollback.

${table}
` + `${buildRecoveryPanel()}

Rollback snapshots

Use these snapshots to roll back failed updates. Major-version rollback is blocked unless the snapshot is marked rollback safe. The server will restart after rollback.

${table}
` ) ); }); @@ -210,6 +261,30 @@ app.post("/rollback/:id", (req, res) => { } }); +app.post("/plugins/:id/disable", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + setPluginEnabled(req.params.id, false); + res.redirect("/"); +}); + +app.post("/recovery/clear-marker", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + clearRecoveryMarker(); + res.redirect("/"); +}); + +app.post("/recovery/retry-normal-startup", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + res.send(renderPage("Safe Mode", "

Retrying normal startup

Safe mode is exiting. The wrapper will restart Lumi normally.

")); + requestRestart(); +}); + const port = Number(process.env.SAFE_MODE_PORT || 3001); app.listen(port, () => { console.log(`Safe mode listening on http://localhost:${port}`); diff --git a/src/main.js b/src/main.js index 76afdc1..bb5a518 100644 --- a/src/main.js +++ b/src/main.js @@ -15,11 +15,20 @@ const { createCommandRouter } = require("./services/command-router"); const { registerTopCommand } = require("./services/top"); const logger = require("./services/logger"); const { isPlatformEnabled } = require("./services/platforms"); +const { + isSafeModeRequested, + markStartupVerification +} = require("./services/recovery-mode"); async function main() { migrate(); ensureDefaults(); logger.hookConsole(); + const safeModeRequested = isSafeModeRequested(); + const startupMarker = markStartupVerification(); + if (startupMarker?.status === "stale") { + console.warn("Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools."); + } const settingsApi = { getSetting, setSetting }; const commandRouter = createCommandRouter({ settings: settingsApi }); @@ -28,7 +37,7 @@ async function main() { let twitchClient = null; let youtubeClient = null; - if (isPlatformEnabled("discord")) { + if (!safeModeRequested && isPlatformEnabled("discord")) { try { discordClient = await startBot({ commandRouter }); } catch (error) { @@ -36,7 +45,7 @@ async function main() { } } - if (isPlatformEnabled("twitch")) { + if (!safeModeRequested && isPlatformEnabled("twitch")) { try { twitchClient = await startTwitchBot({ commandRouter }); } catch (error) { @@ -44,7 +53,7 @@ async function main() { } } - if (isPlatformEnabled("youtube")) { + if (!safeModeRequested && isPlatformEnabled("youtube")) { try { youtubeClient = await startYouTubeBot({ commandRouter }); } catch (error) { @@ -55,6 +64,7 @@ async function main() { const app = createWebServer({ discordClient, loadPlugins: (appInstance, web, webhooks) => { + if (safeModeRequested) return; loadEnabled({ app: appInstance, discordClient, @@ -75,7 +85,7 @@ async function main() { const autoUpdateEnabled = getSetting("auto_update_enabled", false); const intervalMinutes = getSetting("auto_update_interval_minutes", 60); - if (autoUpdateEnabled) { + if (!safeModeRequested && autoUpdateEnabled) { const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000; setInterval(() => { try { diff --git a/src/services/recovery-mode.js b/src/services/recovery-mode.js new file mode 100644 index 0000000..021ef07 --- /dev/null +++ b/src/services/recovery-mode.js @@ -0,0 +1,132 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const repoRoot = path.join(__dirname, "..", ".."); +const recoveryDir = path.join(repoRoot, "data", "recovery"); +const markerPath = path.join(recoveryDir, "update-marker.json"); +const safeModeFlagPath = path.join(recoveryDir, "safe-mode.flag"); + +function ensureRecoveryDir() { + fs.mkdirSync(recoveryDir, { recursive: true }); +} + +function readRecoveryMarker() { + try { + if (!fs.existsSync(markerPath)) return null; + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); + return marker && typeof marker === "object" ? marker : null; + } catch { + return { + id: "unreadable", + status: "failed", + error: "Recovery marker could not be parsed.", + marker_path: markerPath + }; + } +} + +function writeRecoveryMarker(marker) { + ensureRecoveryDir(); + const next = { + id: marker.id || crypto.randomUUID(), + status: marker.status || "pending", + updated_at: new Date().toISOString(), + ...marker + }; + fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8"); + return next; +} + +function createRecoveryMarker(details) { + return writeRecoveryMarker({ + id: crypto.randomUUID(), + status: "pending", + created_at: new Date().toISOString(), + ...details + }); +} + +function updateRecoveryMarker(values) { + const current = readRecoveryMarker() || {}; + return writeRecoveryMarker({ ...current, ...values }); +} + +function markRecoveryMarkerFailed(error) { + return updateRecoveryMarker({ + status: "failed", + error: error?.message || String(error || "Update failed.") + }); +} + +function markRecoveryMarkerComplete(values = {}) { + return updateRecoveryMarker({ + ...values, + status: "completed", + completed_at: new Date().toISOString() + }); +} + +function clearRecoveryMarker() { + try { + fs.rmSync(markerPath, { force: true }); + } catch { + // ignore cleanup failures + } +} + +function markStartupVerification() { + const marker = readRecoveryMarker(); + if (!marker) return null; + if (marker.status === "completed") { + clearRecoveryMarker(); + return { ...marker, cleared: true }; + } + if (["pending", "applying", "verifying"].includes(marker.status)) { + return writeRecoveryMarker({ + ...marker, + status: "stale", + stale_at: new Date().toISOString(), + error: marker.error || "Previous update did not complete startup verification." + }); + } + return marker; +} + +function isSafeModeRequested(argv = process.argv, env = process.env) { + return env.LUMI_SAFE_MODE === "1" || + env.SAFE_MODE === "1" || + argv.includes("--safe-mode") || + fs.existsSync(safeModeFlagPath); +} + +function safeModeStatus() { + const marker = readRecoveryMarker(); + const requested = isSafeModeRequested(); + const incomplete = marker && ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status); + return { + requested, + active: process.env.SAFE_MODE === "1" || process.env.LUMI_SAFE_MODE === "1", + marker, + has_incomplete_marker: Boolean(incomplete), + safe_mode_flag: fs.existsSync(safeModeFlagPath), + marker_path: markerPath, + flag_path: safeModeFlagPath + }; +} + +module.exports = { + recoveryDir, + markerPath, + safeModeFlagPath, + readRecoveryMarker, + writeRecoveryMarker, + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete, + clearRecoveryMarker, + markStartupVerification, + isSafeModeRequested, + safeModeStatus +}; diff --git a/src/services/repo-update.js b/src/services/repo-update.js new file mode 100644 index 0000000..7a78c9e --- /dev/null +++ b/src/services/repo-update.js @@ -0,0 +1,223 @@ +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { syncPluginRegistry, setPluginEnabled } = require("./plugins"); +const { + createSnapshot, + finalizeSnapshot, + discardSnapshot, + restoreSnapshot +} = require("./update-manager"); +const { getUpdateStatus } = require("./update-index"); +const { + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete +} = require("./recovery-mode"); + +const repoRoot = path.join(__dirname, "..", ".."); +const activeOperations = new Set(); + +function runGit(args) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8" + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Git command failed.").trim()); + } + return result.stdout.trim(); +} + +function withOperation(key, fn) { + if (activeOperations.has(key)) { + throw new Error("An update is already running for this target."); + } + activeOperations.add(key); + return Promise.resolve() + .then(fn) + .finally(() => activeOperations.delete(key)); +} + +function emitProgress(publish, event, payload) { + if (typeof publish === "function") { + publish(event, payload, { role: "admin" }); + } +} + +function verifyCoreFiles() { + for (const file of ["package.json", "src/main.js", "src/web/server.js"]) { + if (!fs.existsSync(path.join(repoRoot, file))) { + throw new Error(`Core verification failed: ${file} is missing.`); + } + } +} + +function verifyPluginFiles(pluginId) { + const manifest = path.join(repoRoot, "plugins", pluginId, "plugin.json"); + if (!fs.existsSync(manifest)) { + throw new Error(`Plugin verification failed: ${pluginId}/plugin.json is missing.`); + } + JSON.parse(fs.readFileSync(manifest, "utf8")); +} + +async function applyCoreUpdate({ source = "stable", publish } = {}) { + return withOperation("core", async () => { + const status = getUpdateStatus({ source }); + const target = status.core; + if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked."); + if (!target.update_available) throw new Error("No safe core update target is available."); + const marker = createRecoveryMarker({ + target_kind: "core", + target_id: "core", + from_version: target.current_version, + to_version: target.safe_target_version, + source_branch: target.source_branch, + update_method: "git", + rollback_safe: target.rollback_safe, + major_crossing: target.major_crossing + }); + let snapshot = null; + try { + emitProgress(publish, "update:queued", { target: "core" }); + emitProgress(publish, "update:checking", { target: "core" }); + emitProgress(publish, "update:metadata", target); + updateRecoveryMarker({ status: "applying" }); + snapshot = await createSnapshot({ + type: "bot", + metadata: { + target_kind: "core", + target_id: "core", + from_version: target.current_version, + to_version: target.safe_target_version, + source_branch: target.source_branch, + update_method: "git", + rollback_safe: target.rollback_safe, + recovery_marker_id: marker.id, + major_crossing: target.major_crossing, + migration_notes: target.migration_notes, + danger_notes: target.dangers + } + }); + emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id }); + emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id }); + emitProgress(publish, "update:download", { target: "core", branch: target.source_branch }); + runGit(["fetch", status.remote, target.source_branch]); + emitProgress(publish, "update:apply", { target: "core" }); + runGit(["pull", "--ff-only", status.remote, target.source_branch]); + updateRecoveryMarker({ status: "verifying" }); + emitProgress(publish, "update:verify", { target: "core" }); + verifyCoreFiles(); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + emitProgress(publish, "update:restart_required", { target: "core" }); + emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id }); + return { status: "complete", restart_required: true, snapshot: record, target }; + } catch (error) { + if (snapshot) discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); + emitProgress(publish, "update:failed", { target: "core", error: error.message }); + throw error; + } + }); +} + +async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) { + return withOperation(`plugin:${pluginId}`, async () => { + const status = getUpdateStatus({ source }); + const target = status.plugins.find((plugin) => plugin.id === pluginId); + if (!target) throw new Error("Plugin is not installed."); + if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked."); + if (!target.update_available) throw new Error("No safe plugin update target is available."); + const marker = createRecoveryMarker({ + target_kind: "plugin", + target_id: pluginId, + from_version: target.current_version, + to_version: target.safe_target_version, + source_branch: target.source_branch, + update_method: "git", + rollback_safe: target.rollback_safe, + major_crossing: target.major_crossing + }); + let snapshot = null; + try { + emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId }); + emitProgress(publish, "update:metadata", target); + updateRecoveryMarker({ status: "applying" }); + snapshot = await createSnapshot({ + type: "plugin", + pluginId, + metadata: { + target_kind: "plugin", + target_id: pluginId, + from_version: target.current_version, + to_version: target.safe_target_version, + source_branch: target.source_branch, + update_method: "git", + rollback_safe: target.rollback_safe, + recovery_marker_id: marker.id, + major_crossing: target.major_crossing, + migration_notes: target.migration_notes, + danger_notes: target.dangers + } + }); + emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id }); + emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch }); + runGit(["fetch", status.remote, target.source_branch]); + emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId }); + runGit(["checkout", `${status.remote}/${target.source_branch}`, "--", `plugins/${pluginId}`]); + updateRecoveryMarker({ status: "verifying" }); + emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId }); + verifyPluginFiles(pluginId); + syncPluginRegistry(); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id }); + return { status: "complete", restart_required: false, snapshot: record, target }; + } catch (error) { + if (snapshot) discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); + emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message }); + throw error; + } + }); +} + +async function revertCoreSnapshot(snapshotId, { currentVersion, publish } = {}) { + return withOperation("core:revert", async () => { + emitProgress(publish, "update:revert", { target: "core", snapshot_id: snapshotId }); + const entry = restoreSnapshot(snapshotId, { + expectedType: "bot", + currentVersion + }); + return { status: "reverted", restart_required: true, snapshot: entry }; + }); +} + +async function revertPluginSnapshot(pluginId, snapshotId, { currentVersion, publish } = {}) { + return withOperation(`plugin:${pluginId}:revert`, async () => { + emitProgress(publish, "update:revert", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotId }); + const entry = restoreSnapshot(snapshotId, { + expectedType: "plugin", + expectedPluginId: pluginId, + currentVersion + }); + syncPluginRegistry(); + return { status: "reverted", restart_required: true, snapshot: entry }; + }); +} + +function disablePluginForRecovery(pluginId, publish) { + setPluginEnabled(pluginId, false); + emitProgress(publish, "recovery:plugin_disabled", { plugin_id: pluginId }); + return { status: "disabled", plugin_id: pluginId }; +} + +module.exports = { + applyCoreUpdate, + applyPluginUpdateFromRepo, + revertCoreSnapshot, + revertPluginSnapshot, + disablePluginForRecovery +}; diff --git a/src/services/update-index.js b/src/services/update-index.js new file mode 100644 index 0000000..48fb532 --- /dev/null +++ b/src/services/update-index.js @@ -0,0 +1,261 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { getSetting } = require("./settings"); +const { scanPluginDirectories, getPlugins } = require("./plugins"); +const { + compareSemver, + findSafeTarget, + collectChangelogRange, + normalizeVersions +} = require("./versioning"); +const { listSnapshots } = require("./update-manager"); +const { safeModeStatus } = require("./recovery-mode"); + +const repoRoot = path.join(__dirname, "..", ".."); + +function runGit(args, options = {}) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + timeout: options.timeout || 20000 + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Git command failed.").trim()); + } + return result.stdout.trim(); +} + +function tryGit(args, fallback = "") { + try { + return runGit(args); + } catch { + return fallback; + } +} + +function fetchRemote(remote) { + runGit(["fetch", "--prune", remote]); +} + +function remoteRef(remote, branch) { + return `${remote}/${branch}`; +} + +function readGitFile(ref, filePath) { + const output = tryGit(["show", `${ref}:${filePath}`], ""); + return output || null; +} + +function readJsonGitFile(ref, filePath) { + const raw = readGitFile(ref, filePath); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function readLocalJson(filePath) { + try { + return JSON.parse(fs.readFileSync(path.join(repoRoot, filePath), "utf8")); + } catch { + return null; + } +} + +function resolveSourceBranch(remote, requested = "stable") { + if (requested === "experimental") { + const refs = tryGit([ + "for-each-ref", + "--format=%(refname:short)|%(committerdate:iso8601)", + `refs/remotes/${remote}/experimental-*` + ], ""); + const branches = refs + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ref, date] = line.split("|"); + return { branch: ref.replace(`${remote}/`, ""), date }; + }) + .sort((a, b) => String(b.date).localeCompare(String(a.date))); + if (branches[0]) return branches[0].branch; + } + if (requested && requested !== "stable" && requested !== "main") { + return String(requested).replace(/^origin\//, ""); + } + return "main"; +} + +function parseMarkdownChangelog(raw) { + if (!raw) return []; + const entries = []; + const lines = raw.split(/\r?\n/); + let current = null; + for (const line of lines) { + const heading = line.match(/^#{1,3}\s+\[?v?(\d+\.\d+\.\d+)\]?(.+)?$/i); + if (heading) { + if (current) entries.push(current); + current = { version: heading[1], title: line.replace(/^#+\s+/, ""), changes: [] }; + } else if (current && line.trim()) { + current.changes.push(line.trim().replace(/^[-*]\s*/, "")); + } + } + if (current) entries.push(current); + return entries; +} + +function changelogEntries(ref, basePath = "") { + const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json")); + if (Array.isArray(json)) return json; + if (Array.isArray(json?.versions)) return json.versions; + const md = readGitFile(ref, path.posix.join(basePath, "CHANGELOG.md")); + return parseMarkdownChangelog(md); +} + +function manifestVersions(manifest, fallbackVersion) { + const entries = []; + if (Array.isArray(manifest?.versions)) entries.push(...manifest.versions); + if (manifest?.version) entries.push({ ...manifest, version: manifest.version }); + if (fallbackVersion) entries.push({ version: fallbackVersion }); + return normalizeVersions(entries); +} + +function coreManifest(ref) { + return readJsonGitFile(ref, "update-manifest.json") || + readJsonGitFile(ref, "lumi.manifest.json") || + readJsonGitFile(ref, "package.json") || + {}; +} + +function localCoreVersion() { + return readLocalJson("package.json")?.version || "0.0.0"; +} + +function latestEntry(entries) { + return entries.length ? entries[entries.length - 1] : null; +} + +function snapshotAvailability(kind, id = null) { + const snapshots = listSnapshots() + .filter((snap) => snap.type === kind || (kind === "core" && snap.type === "bot")) + .filter((snap) => !id || snap.pluginId === id) + .sort((a, b) => b.createdAt - a.createdAt); + const latest = snapshots[0] || null; + return { + available: Boolean(latest), + latest_snapshot_id: latest?.id || null, + rollback_safe: latest?.rollback_safe !== false, + latest + }; +} + +function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) { + const versions = manifestVersions(manifest, manifest?.version); + const latest = latestEntry(versions); + const targetResult = findSafeTarget(currentVersion, versions); + const target = targetResult.target; + const range = target ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) : []; + const warnings = []; + const dangers = []; + if (!changelog.length) warnings.push("Changelog metadata is missing."); + if (targetResult.warning) warnings.push(targetResult.warning); + if (targetResult.blocked) dangers.push(targetResult.reason); + if (target?.rollback_safe === false) warnings.push("Target metadata marks rollback as unsafe after migration."); + return { + kind, + id: id || kind, + name: name || manifest?.name || id || "Lumi core", + current_version: currentVersion, + latest_available_version: latest?.version || currentVersion, + safe_target_version: target?.version || null, + update_available: Boolean(target && compareSemver(target.version, currentVersion) > 0), + blocked: Boolean(targetResult.blocked), + blocked_reason: targetResult.reason || null, + source_branch: sourceBranch, + channel: manifest?.channel || channel || "stable", + version_description: target + ? `${currentVersion} -> ${target.version}` + : targetResult.reason || "No safe update target available.", + changelog_range: range, + size_delta: target?.size || manifest?.size || null, + size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown", + warnings, + dangers, + requirements: target?.requirements || manifest?.requirements || [], + migration_notes: target?.migration_notes || manifest?.migration_notes || "", + rollback_safe: target?.rollback_safe !== false, + major_crossing: target ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false, + snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id), + raw_target: target || null + }; +} + +function remotePluginDirs(ref) { + const output = tryGit(["ls-tree", "-d", "--name-only", `${ref}:plugins`], ""); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((item) => item.replace(/^plugins\//, "")); +} + +function getUpdateStatus(options = {}) { + const remote = options.remote || getSetting("git_remote", "origin"); + const requestedSource = options.source || "stable"; + fetchRemote(remote); + const sourceBranch = resolveSourceBranch(remote, requestedSource); + const ref = remoteRef(remote, sourceBranch); + const core = buildStatus({ + kind: "core", + currentVersion: localCoreVersion(), + manifest: coreManifest(ref), + changelog: changelogEntries(ref), + sourceBranch, + channel: requestedSource === "experimental" ? "experimental" : "stable" + }); + const installed = scanPluginDirectories(); + const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin])); + const remoteDirs = new Set(remotePluginDirs(ref)); + const plugins = installed.map((plugin) => { + const basePath = `plugins/${plugin.id}`; + const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {}; + const changelog = changelogEntries(ref, basePath); + const dbPlugin = registry.get(plugin.id); + return buildStatus({ + kind: "plugin", + id: plugin.id, + name: plugin.name, + currentVersion: dbPlugin?.version || plugin.version || "0.0.0", + manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name }, + changelog, + sourceBranch, + channel: requestedSource === "experimental" ? "experimental" : "stable" + }); + }); + return { + generated_at: new Date().toISOString(), + source_branch: sourceBranch, + requested_source: requestedSource, + remote, + core, + plugins, + plugins_summary: { + installed_plugins: installed.length, + total_plugins: remoteDirs.size || installed.length, + updatable_plugins: plugins.filter((plugin) => plugin.update_available).length, + blocked_plugins: plugins.filter((plugin) => plugin.blocked).length + }, + recovery: safeModeStatus() + }; +} + +module.exports = { + getUpdateStatus, + resolveSourceBranch, + fetchRemote, + runGit, + readGitFile +}; diff --git a/src/services/update-manager.js b/src/services/update-manager.js index 1d03142..b224c1a 100644 --- a/src/services/update-manager.js +++ b/src/services/update-manager.js @@ -9,6 +9,12 @@ try { AdmZip = null; } const { db } = require("./db"); +const { + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete +} = require("./recovery-mode"); const repoRoot = path.join(__dirname, "..", ".."); const dataDir = path.join(repoRoot, "data"); @@ -49,7 +55,7 @@ async function backupDatabase(targetPath) { } } -async function createSnapshot({ type, pluginId }) { +async function createSnapshot({ type, pluginId, metadata = {} }) { ensureSnapshotsDir(); const id = `${Date.now()}-${crypto.randomUUID()}`; const snapshotPath = path.join(snapshotsDir, id); @@ -77,7 +83,7 @@ async function createSnapshot({ type, pluginId }) { } } - return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath }; + return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata }; } function finalizeSnapshot(snapshot) { @@ -89,7 +95,18 @@ function finalizeSnapshot(snapshot) { pluginExisted: snapshot.pluginExisted || false, createdAt: Date.now(), status: "available", - path: snapshot.snapshotPath + path: snapshot.snapshotPath, + target_kind: snapshot.metadata?.target_kind || snapshot.type, + target_id: snapshot.metadata?.target_id || snapshot.pluginId || null, + from_version: snapshot.metadata?.from_version || null, + to_version: snapshot.metadata?.to_version || null, + source_branch: snapshot.metadata?.source_branch || null, + update_method: snapshot.metadata?.update_method || null, + rollback_safe: snapshot.metadata?.rollback_safe !== false, + recovery_marker_id: snapshot.metadata?.recovery_marker_id || null, + major_crossing: Boolean(snapshot.metadata?.major_crossing), + migration_notes: snapshot.metadata?.migration_notes || "", + danger_notes: snapshot.metadata?.danger_notes || [] }; entries.push(record); saveIndex(pruneEntries(entries)); @@ -384,16 +401,38 @@ async function applyBotUpdate(zipPath, options = {}) { verifyBotPackage(rootPath); } - const snapshot = await createSnapshot({ type: "bot" }); + const marker = createRecoveryMarker({ + target_kind: "core", + target_id: "core", + from_version: options.metadata?.from_version || null, + to_version: options.metadata?.to_version || null, + source_branch: options.metadata?.source_branch || "manual_zip", + update_method: options.mode === "patch" ? "zip_patch" : "zip", + rollback_safe: options.metadata?.rollback_safe !== false, + major_crossing: Boolean(options.metadata?.major_crossing) + }); + updateRecoveryMarker({ status: "applying" }); + const snapshot = await createSnapshot({ + type: "bot", + metadata: { + target_kind: "core", + update_method: options.mode === "patch" ? "zip_patch" : "zip", + ...options.metadata, + recovery_marker_id: marker.id + } + }); try { if (mode === "patch") { applyCorePatch(rootPath); } else { applyCoreUpdate(rootPath); } - return finalizeSnapshot(snapshot); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + return record; } catch (error) { discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); throw error; } } finally { @@ -401,21 +440,48 @@ async function applyBotUpdate(zipPath, options = {}) { } } -async function applyPluginUpdate(zipPath) { +async function applyPluginUpdate(zipPath, options = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-")); try { extractZip(zipPath, tempDir); const rootPath = resolvePluginRoot(tempDir); const manifest = verifyPluginPackage(rootPath); + if (options.expectedPluginId && manifest.id !== options.expectedPluginId) { + throw new Error(`Plugin ZIP id ${manifest.id} does not match ${options.expectedPluginId}.`); + } - const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id }); + const marker = createRecoveryMarker({ + target_kind: "plugin", + target_id: manifest.id, + from_version: options.metadata?.from_version || null, + to_version: manifest.version || options.metadata?.to_version || null, + source_branch: options.metadata?.source_branch || "manual_zip", + update_method: "zip", + rollback_safe: options.metadata?.rollback_safe !== false, + major_crossing: Boolean(options.metadata?.major_crossing) + }); + updateRecoveryMarker({ status: "applying" }); + const snapshot = await createSnapshot({ + type: "plugin", + pluginId: manifest.id, + metadata: { + target_kind: "plugin", + target_id: manifest.id, + update_method: "zip", + ...options.metadata, + recovery_marker_id: marker.id + } + }); try { applyPluginFiles(rootPath, manifest.id, { preserveData: snapshot.pluginExisted }); - return finalizeSnapshot(snapshot); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + return record; } catch (error) { discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); throw error; } } finally { @@ -436,7 +502,7 @@ function restoreDatabase(snapshotPath) { fs.rmSync(shm, { force: true }); } -function restoreSnapshot(id) { +function restoreSnapshot(id, options = {}) { const entries = loadIndex(); const entry = entries.find((item) => item.id === id); if (!entry) { @@ -445,6 +511,18 @@ function restoreSnapshot(id) { if (entry.status !== "available") { throw new Error("Snapshot is no longer available."); } + if (options.expectedType && entry.type !== options.expectedType) { + throw new Error("Snapshot target type does not match this revert action."); + } + if (options.expectedPluginId && entry.pluginId !== options.expectedPluginId) { + throw new Error("Snapshot target plugin does not match this revert action."); + } + if (entry.major_crossing && entry.rollback_safe === false && !options.allowUnsafeMajorRollback) { + throw new Error("This snapshot crossed a major version and is not marked rollback safe."); + } + if (options.currentVersion && entry.to_version && entry.to_version !== options.currentVersion) { + throw new Error("Only the previous version snapshot can be reverted from this action."); + } if (entry.type === "bot") { const coreZip = path.join(entry.path, "core.zip"); @@ -484,6 +562,9 @@ function restoreSnapshot(id) { module.exports = { applyBotUpdate, applyPluginUpdate, + createSnapshot, + finalizeSnapshot, + discardSnapshot, applyPluginFiles, resetPluginCode, replacePluginDirectory, diff --git a/src/services/versioning.js b/src/services/versioning.js new file mode 100644 index 0000000..25358a4 --- /dev/null +++ b/src/services/versioning.js @@ -0,0 +1,118 @@ +function parseSemver(value) { + const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + raw: `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` + }; +} + +function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + for (const key of ["major", "minor", "patch"]) { + if (a[key] > b[key]) return 1; + if (a[key] < b[key]) return -1; + } + return 0; +} + +function isMajorJump(current, target) { + const from = parseSemver(current); + const to = parseSemver(target); + return Boolean(from && to && from.major !== to.major); +} + +function versionInRange(version, from, to) { + return compareSemver(version, from) > 0 && compareSemver(version, to) <= 0; +} + +function normalizeVersionEntry(entry, fallback = {}) { + if (typeof entry === "string") return { version: entry, ...fallback }; + if (!entry || typeof entry !== "object") return null; + const version = entry.version || entry.name || entry.tag; + if (!parseSemver(version)) return null; + return { ...fallback, ...entry, version: parseSemver(version).raw }; +} + +function normalizeVersions(entries, fallback = {}) { + return (Array.isArray(entries) ? entries : []) + .map((entry) => normalizeVersionEntry(entry, fallback)) + .filter(Boolean) + .sort((a, b) => compareSemver(a.version, b.version)); +} + +function compatibleFromAllows(entry, current) { + if (!entry?.compatible_from) return false; + return compareSemver(current, entry.compatible_from) >= 0; +} + +function isBridgeTarget(entry, current) { + const from = parseSemver(current); + const to = parseSemver(entry?.version); + if (!from || !to || from.major === to.major) return false; + if (entry.compatibility_bridge === true || entry.migration_kind === "compatibility_bridge") { + return compatibleFromAllows(entry, current) || !entry.compatible_from; + } + if (to.major === from.major + 1 && to.minor === 0 && to.patch === 0) { + return compatibleFromAllows(entry, current) || entry.compatible_from === undefined; + } + return false; +} + +function findSafeTarget(currentVersion, versionEntries) { + const current = parseSemver(currentVersion); + const versions = normalizeVersions(versionEntries); + if (!current) { + return { + target: null, + blocked: true, + reason: "Current version is not valid semver." + }; + } + const newer = versions.filter((entry) => compareSemver(entry.version, current.raw) > 0); + if (!newer.length) { + return { target: null, blocked: false, reason: "Already up to date." }; + } + const latest = newer[newer.length - 1]; + const sameMajor = newer.filter((entry) => parseSemver(entry.version)?.major === current.major); + if (sameMajor.length) { + return { target: sameMajor[sameMajor.length - 1], latest, blocked: false }; + } + const bridge = newer.find((entry) => isBridgeTarget(entry, current.raw)); + if (bridge) { + return { + target: bridge, + latest, + blocked: false, + warning: `Latest ${latest.version} crosses a major boundary; targeting compatibility bridge ${bridge.version}.` + }; + } + return { + target: null, + latest, + blocked: true, + reason: `Latest ${latest.version} crosses a major boundary and no compatible bridge target was found.` + }; +} + +function collectChangelogRange(currentVersion, targetVersion, changelogEntries) { + if (!targetVersion) return []; + return normalizeVersions(changelogEntries) + .filter((entry) => versionInRange(entry.version, currentVersion, targetVersion)) + .sort((a, b) => compareSemver(b.version, a.version)); +} + +module.exports = { + parseSemver, + compareSemver, + isMajorJump, + normalizeVersions, + findSafeTarget, + collectChangelogRange +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index afc5342..c9d4802 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -562,6 +562,7 @@ const isDestructiveForm = (form, submitter = null) => { if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; + if (form.dataset.updateAction !== undefined) return false; return String(form.method || "get").toLowerCase() === "post" && destructivePattern.test(destructiveAction(form, submitter)); }; @@ -772,6 +773,97 @@ } }); + const updateLog = document.querySelector("[data-update-progress-log]"); + if (updateLog) { + const appendUpdateLog = (message, level = "info") => { + const row = document.createElement("div"); + row.className = `update-progress-entry ${level}`; + row.textContent = message; + updateLog.prepend(row); + }; + try { + const source = new EventSource("/admin/updates/events"); + [ + "update:queued", + "update:checking", + "update:metadata", + "update:snapshot", + "update:recovery_marker", + "update:download", + "update:apply", + "update:verify", + "update:restart_required", + "update:complete", + "update:failed", + "update:revert", + "recovery:plugin_disabled", + "recovery:retry_startup" + ].forEach((eventName) => { + source.addEventListener(eventName, (event) => { + const payload = JSON.parse(event.data || "{}"); + const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery"; + appendUpdateLog(`${eventName.replace("update:", "").replace("recovery:", "recovery ")}: ${target}`, eventName.includes("failed") ? "danger" : "info"); + }); + }); + } catch { + appendUpdateLog("Live update stream is unavailable.", "danger"); + } + + document.querySelectorAll("form[data-update-action]").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitter = event.submitter || form.querySelector("button[type='submit']"); + const confirmed = form.dataset.confirmMode === "modal" + ? await window.LumiConfirm?.destructive?.({ + title: form.dataset.confirmTitle || "Confirm update action", + text: form.dataset.confirmText || "This update action will change local files.", + label: form.dataset.confirmLabel || submitter?.textContent || "Confirm" + }) + : true; + if (!confirmed) { + window.LumiStateButton?.reset?.(submitter); + return; + } + const originalText = submitter?.textContent; + const isStateButton = submitter?.matches?.("[data-lumi-state-button]"); + if (isStateButton) { + window.LumiStateButton?.setState?.(submitter, "loading", { busy: true }); + } else if (submitter) { + submitter.disabled = true; + submitter.textContent = "Working..."; + } + appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`); + try { + const response = await fetch(form.action, { + method: form.method || "POST", + body: new FormData(form), + headers: { Accept: "application/json" } + }); + const result = await response.json(); + if (!response.ok || result.ok === false) throw new Error(result.error || "Update action failed."); + if (isStateButton) window.LumiStateButton?.success?.(submitter); + else if (submitter) submitter.textContent = "Done"; + appendUpdateLog(result.message || "Update action completed.", "success"); + if (result.refresh_after_ms) { + appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success"); + window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms)); + } + } catch (error) { + if (isStateButton) window.LumiStateButton?.error?.(submitter); + else if (submitter) submitter.textContent = "Failed"; + appendUpdateLog(error.message, "danger"); + } finally { + if (!isStateButton && submitter) { + window.setTimeout(() => { + submitter.disabled = false; + submitter.textContent = originalText; + }, 2500); + } + } + }); + }); + } + document.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", async () => { const text = button.getAttribute("data-copy") || ""; diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 6e0c3e2..6c94230 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -469,6 +469,27 @@ input[type="color"] { color: var(--lumi-success); } +.badge.success { + color: var(--lumi-success); + background: color-mix(in srgb, var(--lumi-success) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-success) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + +.badge.warning { + color: var(--lumi-warning); + background: color-mix(in srgb, var(--lumi-warning) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-warning) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + +.badge.danger { + color: var(--lumi-danger); + background: color-mix(in srgb, var(--lumi-danger) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-danger) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + .status-warning { color: var(--lumi-warning); } @@ -711,6 +732,80 @@ input[type="color"] { color: var(--lumi-text-muted); } +.update-recovery-banner { + border-left: 4px solid var(--lumi-danger); +} + +.update-detail-grid { + display: grid; + gap: var(--lumi-space-4); +} + +.update-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: var(--lumi-space-3); +} + +.update-meta-grid > div { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-2); +} + +.update-meta-grid span { + display: block; + margin-bottom: var(--lumi-space-1); + color: var(--lumi-text-muted); + font-size: 0.82rem; +} + +.plugin-update-list, +.plugin-update-row { + display: grid; + gap: var(--lumi-space-3); +} + +.update-changelog { + display: grid; + gap: var(--lumi-space-2); + padding-left: 0; + list-style: none; +} + +.update-changelog li { + display: grid; + gap: var(--lumi-space-1); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); +} + +.update-progress-log { + display: grid; + gap: var(--lumi-space-2); + max-height: 18rem; + overflow: auto; +} + +.update-progress-entry { + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-left: 3px solid var(--lumi-accent); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-2); +} + +.update-progress-entry.success { + border-left-color: var(--lumi-success); +} + +.update-progress-entry.danger { + border-left-color: var(--lumi-danger); +} + .dashboard-metric-grid, .dashboard-chart-grid { display: grid; diff --git a/src/web/server.js b/src/web/server.js index 689c1c1..cad8c8d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -83,6 +83,19 @@ const { applyPluginUpdate, listSnapshots } = require("../services/update-manager"); +const { getUpdateStatus } = require("../services/update-index"); +const { + applyCoreUpdate, + applyPluginUpdateFromRepo, + revertCoreSnapshot, + revertPluginSnapshot, + disablePluginForRecovery +} = require("../services/repo-update"); +const { + safeModeStatus, + clearRecoveryMarker, + updateRecoveryMarker +} = require("../services/recovery-mode"); const { generateCommandPreview, previewParts @@ -4873,13 +4886,134 @@ function createWebServer({ loadPlugins, discordClient }) { } }); + function wantsJson(req) { + return req.xhr || + req.get("accept")?.includes("application/json") || + req.get("content-type")?.includes("application/json"); + } + + function updateSourceFrom(req) { + const value = req.body?.source || req.query?.source || "stable"; + return value === "experimental" ? "experimental" : String(value || "stable"); + } + + function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") { + if (wantsJson(req)) { + return res.json({ ok: true, ...result }); + } + setFlash(req, "success", result.message || "Update action completed."); + return res.redirect(redirectPath); + } + + function sendUpdateError(req, res, error, redirectPath = "/admin/updates") { + if (wantsJson(req)) { + return res.status(400).json({ ok: false, error: error.message }); + } + setFlash(req, "error", error.message); + return res.redirect(redirectPath); + } + + function scheduleRestartAfterNotice() { + setTimeout(() => requestRestart(), 5000); + } + + app.get("/admin/updates/events", requireRole("admin"), subscribeWebEvents); + + app.get("/admin/updates/status", requireRole("admin"), (req, res) => { + try { + res.json({ ok: true, status: getUpdateStatus({ source: updateSourceFrom(req) }) }); + } catch (error) { + res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.get("/admin/updates/recovery", requireRole("admin"), (req, res) => { + res.json({ ok: true, recovery: safeModeStatus(), snapshots: listSnapshots() }); + }); + + app.post("/admin/updates/recovery/clear-marker", requireRole("admin"), (req, res) => { + clearRecoveryMarker(); + publishWebEvent("recovery:marker_detected", { status: "cleared" }, { role: "admin" }); + sendUpdateResult(req, res, { message: "Recovery marker cleared." }); + }); + + app.post("/admin/updates/recovery/retry-normal-startup", requireRole("admin"), (req, res) => { + updateRecoveryMarker({ status: "retry_startup", retry_at: new Date().toISOString() }); + publishWebEvent("recovery:retry_startup", {}, { role: "admin" }); + sendUpdateResult(req, res, { message: "Retrying normal startup." }); + scheduleRestartAfterNotice(); + }); + app.get("/admin/updates", requireRole("admin"), (req, res) => { + let updateStatus = null; + let updateStatusError = null; + try { + updateStatus = getUpdateStatus({ source: updateSourceFrom(req) }); + } catch (error) { + updateStatusError = error.message; + } res.render("admin-updates", { title: "Updates", - snapshots: listSnapshots() + snapshots: listSnapshots(), + updateStatus, + updateStatusError, + recoveryStatus: safeModeStatus() }); }); + app.post("/admin/updates/core/check", requireRole("admin"), (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + sendUpdateResult(req, res, { + status, + message: status.core.update_available + ? `Safe core target ${status.core.safe_target_version} is available.` + : status.core.blocked + ? status.core.blocked_reason + : "No core updates found." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/core/apply", requireRole("admin"), async (req, res) => { + try { + const result = await applyCoreUpdate({ + source: updateSourceFrom(req), + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + refresh_after_ms: 5000, + message: "Core update applied. Lumi will restart after the confirmation notice." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/core/revert", requireRole("admin"), async (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const snapshotId = req.body.snapshot_id || status.core.snapshot.latest_snapshot_id; + if (!snapshotId) throw new Error("No core snapshot is available to revert."); + const result = await revertCoreSnapshot(snapshotId, { + currentVersion: status.core.current_version, + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + refresh_after_ms: 5000, + message: "Core snapshot reverted. Lumi will restart after the confirmation notice." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + app.post( "/admin/updates/bot", requireRole("admin"), @@ -4918,6 +5052,38 @@ function createWebServer({ loadPlugins, discordClient }) { } ); + app.post( + "/admin/updates/core/zip", + requireRole("admin"), + uploadSingle("update_zip"), + async (req, res) => { + if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError)); + if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive.")); + try { + const patchMode = req.body.patch_mode === "1"; + const snapshot = await applyBotUpdate(req.file.path, { + mode: patchMode ? "patch" : "full", + metadata: { + update_method: patchMode ? "zip_patch" : "zip", + source_branch: "manual_zip", + rollback_safe: req.body.rollback_safe === "1" + } + }); + sendUpdateResult(req, res, { + snapshot, + restart_required: true, + refresh_after_ms: 5000, + message: patchMode ? "Core patch ZIP applied. Restarting..." : "Core ZIP update applied. Restarting..." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } finally { + try { fs.rmSync(req.file?.path, { force: true }); } catch {} + } + } + ); + app.post( "/admin/updates/plugin", requireRole("admin"), @@ -4949,6 +5115,97 @@ function createWebServer({ loadPlugins, discordClient }) { } ); + app.post("/admin/updates/plugins/:id/check", requireRole("admin"), (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const plugin = status.plugins.find((item) => item.id === req.params.id); + if (!plugin) throw new Error("Plugin not found."); + sendUpdateResult(req, res, { + plugin, + message: plugin.update_available + ? `Safe plugin target ${plugin.safe_target_version} is available.` + : plugin.blocked + ? plugin.blocked_reason + : "No plugin updates found." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/apply", requireRole("admin"), async (req, res) => { + try { + const result = await applyPluginUpdateFromRepo(req.params.id, { + source: updateSourceFrom(req), + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + message: "Plugin update applied." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/revert", requireRole("admin"), async (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const plugin = status.plugins.find((item) => item.id === req.params.id); + if (!plugin) throw new Error("Plugin not found."); + const snapshotId = req.body.snapshot_id || plugin.snapshot.latest_snapshot_id; + if (!snapshotId) throw new Error("No plugin snapshot is available to revert."); + const result = await revertPluginSnapshot(req.params.id, snapshotId, { + currentVersion: plugin.current_version, + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + message: "Plugin snapshot reverted. Restarting..." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/disable", requireRole("admin"), (req, res) => { + try { + const result = disablePluginForRecovery(req.params.id, publishWebEvent); + sendUpdateResult(req, res, { ...result, message: "Plugin disabled for recovery." }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post( + "/admin/updates/plugins/:id/zip", + requireRole("admin"), + uploadSingle("plugin_zip"), + async (req, res) => { + if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError)); + if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive.")); + try { + const snapshot = await applyPluginUpdate(req.file.path, { + expectedPluginId: req.params.id, + metadata: { + target_id: req.params.id, + source_branch: "manual_zip", + rollback_safe: req.body.rollback_safe === "1" + } + }); + sendUpdateResult(req, res, { + snapshot, + message: "Plugin ZIP update applied." + }); + } catch (error) { + sendUpdateError(req, res, error); + } finally { + try { fs.rmSync(req.file?.path, { force: true }); } catch {} + } + } + ); + app.post("/admin/update", requireRole("admin"), (req, res) => { try { const remote = getSetting("git_remote", "origin"); diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 8d0ce8e..bff7277 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -1,106 +1,258 @@ <%- include("partials/layout-top", { title }) %> +<% + const status = updateStatus || {}; + const core = status.core || null; + const plugins = status.plugins || []; + const summary = status.plugins_summary || { installed_plugins: 0, total_plugins: 0, updatable_plugins: 0, blocked_plugins: 0 }; + const recovery = recoveryStatus || status.recovery || {}; + const marker = recovery.marker || null; + const selectedSource = status.requested_source || "stable"; + const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success"; + const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current"; + const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : []; +%> +
<%- include("partials/page-header", { eyebrow: "Maintenance", pageTitle: "Updates", - description: "Apply git or ZIP updates with automatic pre-update snapshots." + description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback." }) %> -

Rollback is handled from Safe Mode if something breaks.

-
- -
-

Git updates

-

Check or pull updates from the remote and branch configured in Settings.

-
-
- <%- include("partials/state-button", { - type: "submit", - classes: "subtle", - states: [ - { id: "idle", text: "Check for updates" }, - { id: "loading", text: "Checking", spinner: true }, - { id: "success", text: "Checked" } - ] - }) %> -
-
- <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Update from git" }, - { id: "loading", text: "Updating", spinner: true }, - { id: "success", text: "Updated" } - ] - }) %> -
-
-
- -
-

Upload bot update

-
-
- - <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Upload bot update" }, - { id: "loading", text: "Uploading", spinner: true }, - { id: "success", text: "Uploaded" } - ] - }) %> -
-
- - -
+ <% if (updateStatusError) { %> +
Update metadata could not be loaded: <%= updateStatusError %>
+ <% } %> + + +
+

Stable checks read repo metadata from main. Experimental branches are considered only when selected here.

- -
-

Upload plugin update

-
-
- - <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Upload plugin update" }, - { id: "loading", text: "Uploading", spinner: true }, - { id: "success", text: "Uploaded" } - ] - }) %> + +<% if (recovery.has_incomplete_marker || marker) { %> +
+

Safe Mode / Recovery

+

+ Recovery marker status: + <%= marker?.status || "none" %> + <% if (marker?.target_kind) { %> + for <%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %> + <% } %> +

+ <% if (marker) { %> +
+
From<%= marker.from_version || "unknown" %>
+
To<%= marker.to_version || "unknown" %>
+
Method<%= marker.update_method || "unknown" %>
+
Source<%= marker.source_branch || "unknown" %>
+
Snapshot<%= marker.snapshot_id || "pending" %>
+
Updated<%= marker.updated_at ? new Date(marker.updated_at).toLocaleString() : "unknown" %>
+
+ <% if (marker.error) { %>

<%= marker.error %>

<% } %> + <% } %> +
+ + + +
+ +
+
+
+<% } %> + +
+
> + + + Core + Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %> + + <%= badgeText(core) %> + +
+ <% if (core) { %> +
+
Current<%= core.current_version %>
+
Safe target<%= core.safe_target_version || "None" %>
+
Latest<%= core.latest_available_version %>
+
Source branch<%= core.source_branch %>
+
Size change<%= core.size_delta_label %>
+
Snapshot<%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %>
+
+

<%= core.version_description %>

+ <% if (core.warnings?.length) { %>
Warnings
    <% core.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.dangers?.length) { %>
Dangers
    <% core.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.requirements?.length) { %>
Requirements
    <% core.requirements.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.migration_notes) { %>
Migration notes

<%= core.migration_notes %>

<% } %> +
+

Changelog to target

+ <% if (!changelogItems(core).length) { %> +

No changelog entries were found for this range.

+ <% } else { %> +
    + <% changelogItems(core).forEach((entry) => { %> +
  • <%= entry.version %><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %>
  • + <% }) %> +
+ <% } %> +
+
+
+ + +
+
+ + +
+ <% if (core.snapshot.available) { %> +
+ + + +
+ <% } %> +
+
+ Show advanced ZIP update options +
ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
+
+
+ + +
+ + +
+
+ <% } %>
- -
- -
+
+ + +
+
> + + + Plugins + <%= summary.installed_plugins %> installed · <%= summary.total_plugins %> known · <%= summary.updatable_plugins %> updatable + + "><%= summary.blocked_plugins ? `${summary.blocked_plugins} blocked` : `${summary.updatable_plugins} updates` %> + +
+ <% if (!plugins.length) { %> +
No installed plugins found.
+ <% } %> + <% plugins.forEach((plugin) => { %> +
> + + + <%= plugin.name %> + <%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %> + + <%= badgeText(plugin) %> + +
+
+
Current<%= plugin.current_version %>
+
Safe target<%= plugin.safe_target_version || "None" %>
+
Latest<%= plugin.latest_available_version %>
+
Snapshot<%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %>
+
+

<%= plugin.version_description %>

+ <% if (plugin.warnings?.length) { %>
Warnings
    <% plugin.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.dangers?.length) { %>
Dangers
    <% plugin.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.migration_notes) { %>
Migration notes

<%= plugin.migration_notes %>

<% } %> +

Changelog to target

+ <% if (!changelogItems(plugin).length) { %> +

No changelog entries were found for this range.

+ <% } else { %> +
    + <% changelogItems(plugin).forEach((entry) => { %> +
  • <%= entry.version %><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %>
  • + <% }) %> +
+ <% } %> +
+
+ + +
+
+ + +
+ <% if (plugin.snapshot.available) { %> +
+ + + +
+ <% } %> +
+ +
+
+
+ Show advanced plugin ZIP options +
Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
+
+
+ + +
+ +
+
+
+
+ <% }) %> +
+
+
+ +
+

Live Progress

+
+
Update and recovery progress appears here while actions run.
+
+
+ +

Snapshots

<% if (!snapshots.length) { %>
No snapshots yet.
<% } else { %>
- - - - - - - - - <% snapshots.forEach((snap) => { %> - - - - - <% }) %> - -
SnapshotCreated
<%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %><%= new Date(snap.createdAt).toLocaleString() %>
+ + + + + + + + + + + + <% snapshots.forEach((snap) => { %> + + + + + + + + <% }) %> + +
TargetVersionMethodRollbackCreated
<%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %><%= snap.update_method || "snapshot" %><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %><%= new Date(snap.createdAt).toLocaleString() %>
<% } %>
-<%- include("partials/layout-bottom") %> +<%- include("partials/layout-bottom") %> From d1a5d3b8328099128b23a10434876e5a99f8caf1 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 10:05:37 +0200 Subject: [PATCH 10/10] updates: allow confirmed unversioned repo updates --- docs/updates.md | 6 +++++ src/services/update-index.js | 39 ++++++++++++++++++++++++++++----- src/web/views/admin-updates.ejs | 17 ++++++++++---- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index b57340c..fbaa576 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -22,6 +22,12 @@ install. If the latest version is unsafe, the UI shows the safe target as the actionable version and lists why the absolute latest is not selected. +Updates to or from an unversioned install or target remain available so an admin +can recover directly from the repository. They are treated as manual repo +updates: the action button stays enabled, but the UI shows warnings and the +confirmation modal states that version ordering, changelog range, and rollback +safety cannot be verified from metadata. + ## Metadata The updater looks for these files on the source branch: diff --git a/src/services/update-index.js b/src/services/update-index.js index 48fb532..d46e4e9 100644 --- a/src/services/update-index.js +++ b/src/services/update-index.js @@ -4,6 +4,7 @@ const { spawnSync } = require("child_process"); const { getSetting } = require("./settings"); const { scanPluginDirectories, getPlugins } = require("./plugins"); const { + parseSemver, compareSemver, findSafeTarget, collectChangelogRange, @@ -123,6 +124,10 @@ function manifestVersions(manifest, fallbackVersion) { return normalizeVersions(entries); } +function manifestRawVersion(manifest, fallbackVersion) { + return String(manifest?.version || fallbackVersion || "").trim(); +} + function coreManifest(ref) { return readJsonGitFile(ref, "update-manifest.json") || readJsonGitFile(ref, "lumi.manifest.json") || @@ -154,10 +159,30 @@ function snapshotAvailability(kind, id = null) { function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) { const versions = manifestVersions(manifest, manifest?.version); + const rawVersion = manifestRawVersion(manifest); + const targetVersion = rawVersion || latestEntry(versions)?.version || ""; + const currentIsVersioned = Boolean(parseSemver(currentVersion)); + const targetIsVersioned = Boolean(parseSemver(targetVersion)); + const unversionedUpdate = !currentIsVersioned || !targetIsVersioned; const latest = latestEntry(versions); - const targetResult = findSafeTarget(currentVersion, versions); + const unversionedTarget = { + ...(latest || manifest || {}), + version: targetVersion || "unversioned", + rollback_safe: false, + unversioned: true + }; + const targetResult = unversionedUpdate + ? { + target: unversionedTarget, + latest: latest || unversionedTarget, + blocked: false, + warning: "This update involves an unversioned source or target. It is available as a manual repo update, but version ordering, changelog range, and rollback safety cannot be verified." + } + : findSafeTarget(currentVersion, versions); const target = targetResult.target; - const range = target ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) : []; + const range = target && !unversionedUpdate + ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) + : changelog; const warnings = []; const dangers = []; if (!changelog.length) warnings.push("Changelog metadata is missing."); @@ -169,9 +194,11 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour id: id || kind, name: name || manifest?.name || id || "Lumi core", current_version: currentVersion, - latest_available_version: latest?.version || currentVersion, + latest_available_version: latest?.version || rawVersion || currentVersion, safe_target_version: target?.version || null, - update_available: Boolean(target && compareSemver(target.version, currentVersion) > 0), + update_available: Boolean( + target && (unversionedUpdate || compareSemver(target.version, currentVersion) > 0) + ), blocked: Boolean(targetResult.blocked), blocked_reason: targetResult.reason || null, source_branch: sourceBranch, @@ -184,10 +211,12 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown", warnings, dangers, + unversioned_update: unversionedUpdate, + requires_manual_confirmation: unversionedUpdate, requirements: target?.requirements || manifest?.requirements || [], migration_notes: target?.migration_notes || manifest?.migration_notes || "", rollback_safe: target?.rollback_safe !== false, - major_crossing: target ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false, + major_crossing: target && !unversionedUpdate ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false, snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id), raw_target: target || null }; diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index bff7277..eaaa1cb 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -10,6 +10,13 @@ const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success"; const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current"; const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : []; + const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target"; + const applyConfirmText = (item, label) => { + const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`; + if (!item?.unversioned_update) return base; + const warnings = (item.warnings || []).join(" "); + return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`; + }; %>
@@ -87,6 +94,7 @@

<%= core.version_description %>

<% if (core.warnings?.length) { %>
Warnings
    <% core.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.unversioned_update) { %>
Manual confirmation required

This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> <% if (core.dangers?.length) { %>
Dangers
    <% core.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.requirements?.length) { %>
Requirements
    <% core.requirements.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.migration_notes) { %>
Migration notes

<%= core.migration_notes %>

<% } %> @@ -107,9 +115,9 @@ -
+ " data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>"> - +
<% if (core.snapshot.available) { %>
@@ -167,6 +175,7 @@

<%= plugin.version_description %>

<% if (plugin.warnings?.length) { %>
Warnings
    <% plugin.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.unversioned_update) { %>
Manual confirmation required

This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> <% if (plugin.dangers?.length) { %>
Dangers
    <% plugin.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (plugin.migration_notes) { %>
Migration notes

<%= plugin.migration_notes %>

<% } %>

Changelog to target

@@ -184,9 +193,9 @@
-
+ " data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>"> - +
<% if (plugin.snapshot.available) { %>