From b3a499536fd5195bfe2dee84860256fa4b50fa97 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 08:20:38 +0200 Subject: [PATCH] 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 %>