ui: complete webui corrective pass

This commit is contained in:
Franz Rolfsvaag 2026-06-16 08:20:38 +02:00
parent 0d4431924a
commit b3a499536f
22 changed files with 1088 additions and 196 deletions

View File

@ -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 server accepts only six-digit hex colors, supported font presets, bounded metric
values, and readable text/button/input contrast. values, and readable text/button/input contrast.
The live preview updates colors, role colors, metrics, and typography before Draft values are isolated to preview roots. The compact preview and pop-out
save. The editor also shows contrast warnings for the current preview mode, preview update colors, role colors, metrics, spacing, and typography before
offers a reset-to-base action for inherited custom themes, and provides an save, but the editor shell and live site keep the active saved theme until the
optional desktop pop-out preview window that stays synchronized with the editor. 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 Missing or invalid stored values are replaced from the custom theme's built-in
base. Existing installations with modified legacy `theme_light_*`, 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. 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 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: requires selecting an installed model. Main context, gate context, and output
Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported token budgets use shared presets from Tiny (256) through Extra extended
freeform context values are rejected server-side. (32768). Unsupported freeform values are rejected server-side.
AI feedback supports `feedback_kind` values `strict_correction` and AI feedback supports `feedback_kind` values `strict_correction` and
`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where `instruction_based`; instruction-based feedback is the default because most
the model called the wrong tool or failed to call an expected tool. Review, reviews are guidance for future replies rather than exact replacement answers.
edit, and implementation views show both the kind and tag so admins can tell Feedback tags include `wrong_tool_usage` for cases where the model called the
direct answer corrections from broader tool-calling or instruction guidance. 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 ## Homepage Content
Admins can define homepage external link buttons in `homepage_link_buttons` from Admins configure homepage external link buttons from Admin > Settings with the
Admin > Settings. Each entry may include `enabled`, `label`, `description`, Homepage content builder. It writes the existing `homepage_link_buttons` JSON
`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and setting behind the scenes. Each entry may include `enabled`, `label`,
`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are `description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`,
filtered server-side by permission. `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`. Admins configure priority-based hero entries with the same builder; it writes
Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`, the existing `homepage_hero_entries` JSON setting behind the scenes. The
`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and homepage renders the first enabled, available entry the current user can access.
`none`. The homepage renders the first enabled, available entry the current user Hero entries support type, priority/order, permission, source/embed/image URLs,
can access. Hero entries support priority/order, permission, source/embed/image video IDs, availability mode, autoplay mode metadata, and duration fields. Slow
URLs, video IDs, availability mode, autoplay mode metadata, and duration fields. external availability checks are intentionally avoided; entries fail closed if
Slow external availability checks are intentionally avoided; entries fail required local configuration is missing.
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 ## Visual references

View File

@ -16,7 +16,7 @@ const FEEDBACK_TAGS = Object.freeze([
"wrong_tool_usage" "wrong_tool_usage"
]); ]);
const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]); const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
class FeedbackStore { class FeedbackStore {
constructor(options = {}) { constructor(options = {}) {
@ -39,7 +39,7 @@ class FeedbackStore {
feedback_tag: tag, feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind ? input.feedback_kind
: "strict_correction", : "instruction_based",
optional_correction: clean(input.optional_correction, 16000), optional_correction: clean(input.optional_correction, 16000),
status: "pending", status: "pending",
submitted_by: String(actor?.id || "anonymous"), submitted_by: String(actor?.id || "anonymous"),
@ -76,7 +76,7 @@ class FeedbackStore {
return this.mutate(id, (entry) => ({ return this.mutate(id, (entry) => ({
...entry, ...entry,
feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag, 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), optional_correction: clean(values.optional_correction, 16000),
review_notes: clean(values.review_notes, 4000), review_notes: clean(values.review_notes, 4000),
reviewed_by: String(actor.id), reviewed_by: String(actor.id),

View File

@ -39,12 +39,18 @@ const storage = require("./backend/storage");
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
const PLUGIN_ID = "lumi_ai"; const PLUGIN_ID = "lumi_ai";
const CONTEXT_OPTIONS = Object.freeze([ const TOKEN_PRESETS = Object.freeze([
{ label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." }, { 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: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." },
{ label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." }, { label: "Large (8192)", value: 8192, description: "Longer conversations and documents." },
{ label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." } { 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 modelManifest = require("./models_manifest.json");
const runtimeManifest = require("./runtime_manifest.json"); const runtimeManifest = require("./runtime_manifest.json");
@ -322,6 +328,8 @@ module.exports = {
installedModels, installedModels,
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id), selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
contextOptions: CONTEXT_OPTIONS, contextOptions: CONTEXT_OPTIONS,
tokenPresets: TOKEN_PRESETS,
gateContextOptions: GATE_CONTEXT_OPTIONS,
runtimeTarget, runtimeTarget,
runtimeManifest, runtimeManifest,
runtimeStatus, runtimeStatus,
@ -371,8 +379,23 @@ module.exports = {
return flash(req, res, "error", "Choose a supported AI context size."); return flash(req, res, "error", "Choose a supported AI context size.");
} }
const contextSize = requestedContext; 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; const previousConfig = config;
config = saveConfig({ let nextConfig;
try {
nextConfig = {
...config, ...config,
enabled: req.body.enabled === "on", enabled: req.body.enabled === "on",
selected_model_id: model.id, selected_model_id: model.id,
@ -385,13 +408,13 @@ module.exports = {
request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), 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), 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), 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: { output_budgets: {
navigation_help: boundedInt(req.body.output_budget_navigation_help, 64, 32768, 256), navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"),
simple_answer: boundedInt(req.body.output_budget_simple_answer, 64, 32768, 512), simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"),
code_custom_command: boundedInt(req.body.output_budget_code_custom_command, 64, 32768, 896), code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"),
admin_debug: boundedInt(req.body.output_budget_admin_debug, 64, 32768, 1280), admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"),
explicit_long: boundedInt(req.body.output_budget_explicit_long, 64, 32768, 2048) explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens")
}, },
batch_size: boundedInt(req.body.batch_size, 32, 4096, 512), batch_size: boundedInt(req.body.batch_size, 32, 4096, 512),
ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128), ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128),
@ -443,7 +466,7 @@ module.exports = {
gate: { gate: {
...config.gate, ...config.gate,
model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id, 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), threads: boundedInt(req.body.gate_threads, 1, 16, 2),
timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000), 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), 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), trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers),
corrections_enabled: req.body.corrections_enabled === "on" corrections_enabled: req.body.corrections_enabled === "on"
} }
}); };
} catch (error) {
return flash(req, res, "error", error.message);
}
config = saveConfig(nextConfig);
registerAssistantCommands({ registerAssistantCommands({
commandRouter, commandRouter,
provider, provider,
@ -525,11 +552,15 @@ module.exports = {
router.post("/download/runtime", (req, res) => { router.post("/download/runtime", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res); if (!req.session.user?.isAdmin) return denied(res);
const wantsJson = req.accepts(["json", "html"]) === "json";
const hardware = detectHardware(modelManifest.models, runtimeManifest); const hardware = detectHardware(modelManifest.models, runtimeManifest);
const target = getRuntimeTarget(hardware); 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 { try {
downloads.start({ const job = downloads.start({
id: "runtime", id: "runtime",
...target, ...target,
kind: "runtime", kind: "runtime",
@ -540,29 +571,37 @@ module.exports = {
target: target.filename target: target.filename
} }
}); });
if (wantsJson) return res.json({ success: true, job });
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`); return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`);
} catch (error) { } catch (error) {
if (wantsJson) return res.status(400).json({ error: error.message });
return flash(req, res, "error", error.message); return flash(req, res, "error", error.message);
} }
}); });
router.post("/download/model/:id", (req, res) => { router.post("/download/model/:id", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res); if (!req.session.user?.isAdmin) return denied(res);
const wantsJson = req.accepts(["json", "html"]) === "json";
const model = getModel(req.params.id); 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 ( if (
(model.id === config.selected_model_id && runtime.status().state === "running") || (model.id === config.selected_model_id && runtime.status().state === "running") ||
(model.id === config.gate.model_id && gateRuntime.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."); return flash(req, res, "error", "Stop the AI runtimes before replacing an active model.");
} }
const hardware = detectHardware(modelManifest.models); const hardware = detectHardware(modelManifest.models);
const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb; 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 (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."); return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
} }
try { try {
downloads.start({ const job = downloads.start({
id: `model:${model.id}`, id: `model:${model.id}`,
url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`, url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`,
filename: model.filename, filename: model.filename,
@ -570,8 +609,10 @@ module.exports = {
size: model.size, size: model.size,
kind: "model" kind: "model"
}); });
if (wantsJson) return res.json({ success: true, job });
return flash(req, res, "success", `${model.label} download started.`); return flash(req, res, "success", `${model.label} download started.`);
} catch (error) { } catch (error) {
if (wantsJson) return res.status(400).json({ error: error.message });
return flash(req, res, "error", error.message); return flash(req, res, "error", error.message);
} }
}); });

View File

@ -300,8 +300,8 @@
const kind = document.createElement("select"); const kind = document.createElement("select");
kind.setAttribute("aria-label", "Feedback type"); kind.setAttribute("aria-label", "Feedback type");
for (const [value, label] of [ 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"); const option = document.createElement("option");
option.value = value; option.value = value;

View File

@ -7,27 +7,66 @@
const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]"); const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]");
const gpuControl = document.querySelector("[data-gpu-control]"); const gpuControl = document.querySelector("[data-gpu-control]");
const accessForm = document.querySelector("[data-ai-access-form]"); const accessForm = document.querySelector("[data-ai-access-form]");
const runtimePrimary = document.querySelector("[data-runtime-primary]");
if (actions) { 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) => { 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; if (!button) return;
const action = primaryButton
? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start")
: button.dataset.runtimeAction;
if (!action) return;
button.disabled = true; button.disabled = true;
if (primaryButton && window.LumiStateButton) {
window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true });
}
try { 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(); const data = await response.json();
if (!response.ok) throw new Error(data.error || "Runtime action failed."); if (!response.ok) throw new Error(data.error || "Runtime action failed.");
if (data.state) state.textContent = data.state; 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." }; 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) { } catch (error) {
if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button);
window.alert(error.message); window.alert(error.message);
} finally { } finally {
button.disabled = false; 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 () => { const pollDownloads = async () => {
if (!downloadStatus) return; if (!downloadStatus) return;
try { try {
@ -41,9 +80,19 @@
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`; return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`;
}).join(" | "); }).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); if (active.length) window.setTimeout(pollDownloads, 1000);
} catch {} } catch {}
}; };
pollDownloads();
if (testForm && testOutput) { if (testForm && testOutput) {
const updateTestToolsNotice = () => { const updateTestToolsNotice = () => {
if (!testToolsNotice) return; if (!testToolsNotice) return;

View File

@ -93,7 +93,7 @@
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form"> <form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form">
<input type="hidden" name="action" value="edit" /> <input type="hidden" name="action" value="edit" />
<div class="field"><label>Feedback tag</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag %></option><% }) %></select></div> <div class="field"><label>Feedback tag</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag %></option><% }) %></select></div>
<div class="field"><label>Feedback type</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "strict_correction") ? "selected" : "" %>><%= kind.replaceAll("_", " ") %></option><% }) %></select></div> <div class="field"><label>Feedback type</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "instruction_based") ? "selected" : "" %>><%= kind.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field full"><label>Correction or instruction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div> <div class="field full"><label>Correction or instruction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
<div class="field full"><label>Review notes</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div> <div class="field full"><label>Review notes</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div>
<div class="field full improvement-actions"><button class="button" type="submit">Save review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div> <div class="field full improvement-actions"><button class="button" type="submit">Save review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>

View File

@ -1,5 +1,17 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %> <%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" /> <link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
<%
const renderPresetOptions = (options, current) => {
const value = Number(current);
const hasValue = options.some((option) => option.value === value);
let html = "";
if (!hasValue && Number.isFinite(value)) {
html += `<option value="${value}" selected disabled>Unsupported current value (${value})</option>`;
}
html += options.map((option) => `<option value="${option.value}" ${option.value === value ? "selected" : ""}>${option.label}</option>`).join("");
return html;
};
%>
<section class="ai-titlebar"> <section class="ai-titlebar">
<div> <div>
@ -70,19 +82,39 @@
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify"> <form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify">
<button class="button subtle" type="submit">Verify</button> <button class="button subtle" type="submit">Verify</button>
</form> </form>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>"> <form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<button class="button subtle" type="submit">Redownload</button> <%- 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" }
]
}) %>
</form> </form>
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-form="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?"> <form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
<input type="hidden" name="confirm" value="yes" /> <input type="hidden" name="confirm" value="yes" />
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button> <button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
</form> </form>
<% } else { %> <% } else { %>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>"> <form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<% if (!model.compatible) { %> <% if (!model.compatible) { %>
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label> <label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
<% } %> <% } %>
<button class="button subtle" type="submit">Download</button> <%- 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" }
]
}) %>
</form> </form>
<% } %> <% } %>
</div> </div>
@ -95,12 +127,25 @@
<div class="ai-section-heading"> <div class="ai-section-heading">
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div> <div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
<div class="ai-actions" data-ai-runtime-actions> <div class="ai-actions" data-ai-runtime-actions>
<button class="button" type="button" data-runtime-action="start">Start</button> <%- 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" }
]
}) %>
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button> <button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button> <button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button> <button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
<button class="button subtle" type="button" data-runtime-action="verify-gate-model">Verify gate model</button> <button class="button subtle" type="button" data-runtime-action="verify-gate-model">Verify gate model</button>
<button class="button subtle" type="button" data-runtime-action="restart">Restart</button>
<button class="button danger" type="button" data-runtime-action="stop">Stop</button> <button class="button danger" type="button" data-runtime-action="stop">Stop</button>
</div> </div>
</div> </div>
@ -138,8 +183,18 @@
<% if (runtimeTarget) { %> <% if (runtimeTarget) { %>
<p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p> <p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
<p class="hint"><%= runtimeTarget.filename %> &middot; <%= formatBytes(runtimeTarget.size) %></p> <p class="hint"><%= runtimeTarget.filename %> &middot; <%= formatBytes(runtimeTarget.size) %></p>
<form method="post" action="/plugins/lumi_ai/download/runtime"> <form method="post" action="/plugins/lumi_ai/download/runtime" data-ai-download-form data-download-id="runtime">
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button> <%- 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" }
]
}) %>
</form> </form>
<% } else { %> <% } else { %>
<div class="callout">No managed runtime build is available for this OS and architecture.</div> <div class="callout">No managed runtime build is available for this OS and architecture.</div>
@ -189,7 +244,7 @@
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div> <div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
<% }) %> <% }) %>
</div> </div>
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-form="Delete the selected plugin-local storage categories?"> <form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label> <label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label> <label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label> <label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
@ -265,12 +320,12 @@
<div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div> <div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div>
<div class="field"><label>UI soft timeout (ms)</label><input type="number" name="ui_soft_timeout_ms" min="5000" max="300000" value="<%= config.ui_soft_timeout_ms %>" /><span class="hint">Shows Continue waiting controls without stopping the job.</span></div> <div class="field"><label>UI soft timeout (ms)</label><input type="number" name="ui_soft_timeout_ms" min="5000" max="300000" value="<%= config.ui_soft_timeout_ms %>" /><span class="hint">Shows Continue waiting controls without stopping the job.</span></div>
<div class="field"><label>Hard generation timeout (ms)</label><input type="number" name="hard_generation_timeout_ms" min="30000" max="3600000" value="<%= config.hard_generation_timeout_ms %>" /></div> <div class="field"><label>Hard generation timeout (ms)</label><input type="number" name="hard_generation_timeout_ms" min="30000" max="3600000" value="<%= config.hard_generation_timeout_ms %>" /></div>
<div class="field"><label>API/test output token fallback</label><input type="number" name="max_output_tokens" min="64" max="32768" value="<%= config.max_output_tokens %>" /><span class="hint">Normal assistant requests use the class budgets below.</span></div> <div class="field"><label>API/test output token fallback</label><select name="max_output_tokens"><%- renderPresetOptions(tokenPresets, config.max_output_tokens) %></select><span class="hint">Normal assistant requests use the class budgets below.</span></div>
<div class="field"><label>Navigation/help tokens</label><input type="number" name="output_budget_navigation_help" min="64" max="32768" value="<%= config.output_budgets.navigation_help %>" /></div> <div class="field"><label>Navigation/help tokens</label><select name="output_budget_navigation_help"><%- renderPresetOptions(tokenPresets, config.output_budgets.navigation_help) %></select></div>
<div class="field"><label>Simple answer tokens</label><input type="number" name="output_budget_simple_answer" min="64" max="32768" value="<%= config.output_budgets.simple_answer %>" /></div> <div class="field"><label>Simple answer tokens</label><select name="output_budget_simple_answer"><%- renderPresetOptions(tokenPresets, config.output_budgets.simple_answer) %></select></div>
<div class="field"><label>Code/custom command tokens</label><input type="number" name="output_budget_code_custom_command" min="64" max="32768" value="<%= config.output_budgets.code_custom_command %>" /></div> <div class="field"><label>Code/custom command tokens</label><select name="output_budget_code_custom_command"><%- renderPresetOptions(tokenPresets, config.output_budgets.code_custom_command) %></select></div>
<div class="field"><label>Admin debug tokens</label><input type="number" name="output_budget_admin_debug" min="64" max="32768" value="<%= config.output_budgets.admin_debug %>" /></div> <div class="field"><label>Admin debug tokens</label><select name="output_budget_admin_debug"><%- renderPresetOptions(tokenPresets, config.output_budgets.admin_debug) %></select></div>
<div class="field"><label>Explicit long-answer tokens</label><input type="number" name="output_budget_explicit_long" min="64" max="32768" value="<%= config.output_budgets.explicit_long %>" /></div> <div class="field"><label>Explicit long-answer tokens</label><select name="output_budget_explicit_long"><%- renderPresetOptions(tokenPresets, config.output_budgets.explicit_long) %></select></div>
<div class="field"><label>Batch size</label><input type="number" name="batch_size" min="32" max="4096" value="<%= config.batch_size %>" /></div> <div class="field"><label>Batch size</label><input type="number" name="batch_size" min="32" max="4096" value="<%= config.batch_size %>" /></div>
<div class="field"><label>Micro-batch size</label><input type="number" name="ubatch_size" min="16" max="4096" value="<%= config.ubatch_size %>" /></div> <div class="field"><label>Micro-batch size</label><input type="number" name="ubatch_size" min="16" max="4096" value="<%= config.ubatch_size %>" /></div>
<div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div> <div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div>
@ -285,7 +340,7 @@
<select name="gate_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.gate.model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select> <select name="gate_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.gate.model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
<span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span> <span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span>
</div> </div>
<div class="field"><label>Gate context size</label><input type="number" name="gate_context_size" min="512" max="4096" value="<%= config.gate.context_size %>" /></div> <div class="field"><label>Gate context size</label><select name="gate_context_size"><%- renderPresetOptions(gateContextOptions, config.gate.context_size) %></select></div>
<div class="field"><label>Gate CPU threads</label><input type="number" name="gate_threads" min="1" max="16" value="<%= config.gate.threads %>" /></div> <div class="field"><label>Gate CPU threads</label><input type="number" name="gate_threads" min="1" max="16" value="<%= config.gate.threads %>" /></div>
<div class="field"><label>Gate timeout (ms)</label><input type="number" name="gate_timeout_ms" min="1000" max="5000" step="250" value="<%= config.gate.timeout_ms %>" /><span class="hint">Timeout or errors immediately escalate to the main model.</span></div> <div class="field"><label>Gate timeout (ms)</label><input type="number" name="gate_timeout_ms" min="1000" max="5000" step="250" value="<%= config.gate.timeout_ms %>" /><span class="hint">Timeout or errors immediately escalate to the main model.</span></div>
<div class="field"><label>High-confidence threshold</label><input type="number" name="gate_high_confidence_threshold" min="0.5" max="0.99" step="0.01" value="<%= config.gate.high_confidence_threshold %>" /></div> <div class="field"><label>High-confidence threshold</label><input type="number" name="gate_high_confidence_threshold" min="0.5" max="0.99" step="0.01" value="<%= config.gate.high_confidence_threshold %>" /></div>
@ -482,7 +537,7 @@
<input type="hidden" name="source" value="local" /> <input type="hidden" name="source" value="local" />
<button class="button subtle" type="submit">Refresh local</button> <button class="button subtle" type="submit">Refresh local</button>
</form> </form>
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-form="Download and index the approved public Lumi repository?"> <form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-title="Refresh public repository index" data-confirm-text="Download and index the approved public Lumi repository?" data-confirm-label="Refresh public">
<input type="hidden" name="source" value="public" /> <input type="hidden" name="source" value="public" />
<button class="button subtle" type="submit">Refresh public</button> <button class="button subtle" type="submit">Refresh public</button>
</form> </form>
@ -578,7 +633,7 @@
<td class="ai-table-actions"> <td class="ai-table-actions">
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a> <a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a> <a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-form="Delete <%= file.name %>?"><button class="button danger" type="submit">Delete</button></form> <form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
</td> </td>
</tr> </tr>
<% }) %> <% }) %>

View File

@ -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", () => { media.addEventListener?.("change", () => {
body.classList.remove("sidebar-open"); body.classList.remove("sidebar-open");
if (media.matches) { 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) => { const isDestructiveForm = (form) => {
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
return String(form.method || "get").toLowerCase() === "post" && return String(form.method || "get").toLowerCase() === "post" &&
@ -567,12 +594,14 @@
form.requestSubmit(submitter?.form === form ? submitter : undefined); 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 startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
const state = destructiveStates.get(form) || {}; const state = destructiveStates.get(form) || {};
const update = () => { const update = () => {
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000)); const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
button.disabled = remaining > 0; 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) { if (!remaining && state.timer) {
window.clearInterval(state.timer); window.clearInterval(state.timer);
state.timer = null; state.timer = null;
@ -592,14 +621,15 @@
const action = destructiveAction(form); const action = destructiveAction(form);
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null }; const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
destructiveStates.set(form, state); 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"); const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
let confirmButton; let confirmButton;
if (mode === "modal" && destructiveModal && destructiveConfirm) { if (mode === "modal" && destructiveModal && destructiveConfirm) {
if (activeDestructive?.form) resetDestructive(activeDestructive.form); if (activeDestructive?.form) resetDestructive(activeDestructive.form);
activeDestructive = { form }; activeDestructive = { form };
destructiveTitle.textContent = form.dataset.confirmTitle || "Confirm destructive action"; destructiveTitle.textContent = form.dataset.confirmTitle || copy.title;
destructiveDescription.textContent = message; destructiveDescription.textContent = message;
destructiveConfirm.disabled = true; destructiveConfirm.disabled = true;
destructiveConfirm.textContent = "Preparing..."; destructiveConfirm.textContent = "Preparing...";
@ -678,6 +708,7 @@
form.dataset.syntheticConfirmation = "true"; form.dataset.syntheticConfirmation = "true";
form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action"; form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action";
form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone."; form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone.";
form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm";
document.body.append(form); document.body.append(form);
issueDestructiveConfirmation(form, null); issueDestructiveConfirmation(form, null);
}, true); }, true);

View File

@ -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 = `<polyline points="${line(values)}" fill="none" stroke="var(--lumi-primary)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>`;
};
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 `<rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${label === "error" ? "danger" : label === "warn" ? "warning" : label === "info" ? "info" : "text-muted"})"></rect><text x="${x + 19}" y="116" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${label}</text>`;
}).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);
})();

View File

@ -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();
});
})();

View File

@ -182,6 +182,8 @@ section.card:has(> table.table) {
button.button, button.button,
input[type="submit"].button { input[type="submit"].button {
min-height: var(--lumi-control-height); min-height: var(--lumi-control-height);
width: max-content;
max-width: 100%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -246,6 +248,7 @@ button:disabled {
.lumi-state-btn { .lumi-state-btn {
position: relative; position: relative;
width: max-content;
} }
.lumi-state-btn[aria-busy="true"] { .lumi-state-btn[aria-busy="true"] {
@ -253,6 +256,8 @@ button:disabled {
} }
.lumi-state-btn-content { .lumi-state-btn-content {
width: max-content;
max-width: 100%;
display: grid; display: grid;
grid-template-areas: "stack"; grid-template-areas: "stack";
align-items: center; align-items: center;
@ -265,10 +270,21 @@ button:disabled {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
white-space: nowrap;
} }
.lumi-state-btn [data-state-view][hidden] { .lumi-state-btn [data-state-view][data-state-hidden="true"] {
display: none !important; 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 { .lumi-state-btn-spinner {
@ -317,6 +333,18 @@ button:disabled {
gap: var(--lumi-space-2); 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, .field > label:first-child,
fieldset > legend { fieldset > legend {
color: var(--lumi-text); color: var(--lumi-text);
@ -610,6 +638,123 @@ input[type="color"] {
background: var(--lumi-surface-subtle); 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 { .modal-backdrop {
padding: var(--lumi-space-4); padding: var(--lumi-space-4);
background: rgba(5, 10, 12, 0.62); background: rgba(5, 10, 12, 0.62);
@ -824,6 +969,14 @@ details > summary {
width: 100%; width: 100%;
} }
.input-action-row {
grid-template-columns: 1fr;
}
.homepage-builder-row {
grid-template-columns: 1fr;
}
.list li { .list li {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;

View File

@ -14,7 +14,7 @@
getViews(button).forEach((view) => { getViews(button).forEach((view) => {
const isVisible = view.dataset.stateView === nextState; const isVisible = view.dataset.stateView === nextState;
view.hidden = !isVisible; view.dataset.stateHidden = isVisible ? "false" : "true";
view.setAttribute("aria-hidden", isVisible ? "false" : "true"); view.setAttribute("aria-hidden", isVisible ? "false" : "true");
}); });

View File

@ -297,6 +297,10 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.theme-mobile-preview-action {
display: none;
}
.theme-preview-window { .theme-preview-window {
min-height: 26rem; min-height: 26rem;
display: grid; display: grid;
@ -326,6 +330,11 @@
background: var(--lumi-border); background: var(--lumi-border);
} }
.theme-preview-nav > span.is-active {
width: 2.35rem;
background: var(--lumi-primary);
}
.theme-preview-logo { .theme-preview-logo {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@ -344,28 +353,21 @@
padding: var(--lumi-space-4); padding: var(--lumi-space-4);
} }
.theme-preview-heading { .theme-preview-content h2 {
width: 60%; margin-bottom: 0;
height: 1.2rem; 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); border-radius: var(--lumi-radius-pill);
background: var(--lumi-text); background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface));
}
.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 { .theme-preview-sample-card {
@ -385,6 +387,13 @@
margin-top: var(--lumi-space-3); 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 { .theme-preview-statuses {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -394,6 +403,73 @@
font-weight: 700; 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 { .is-selected {
border-color: var(--lumi-primary) !important; border-color: var(--lumi-primary) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important;
@ -439,18 +515,11 @@
height: 5.5rem; height: 5.5rem;
} }
.theme-preview-window { .theme-mobile-preview-action {
min-height: 16rem; display: inline-flex;
grid-template-columns: 3.25rem minmax(0, 1fr);
} }
.theme-preview-nav, .theme-preview {
.theme-preview-content,
.theme-preview-sample-card {
padding: var(--lumi-space-3);
}
.theme-popout-button {
display: none; display: none;
} }

View File

@ -3,8 +3,7 @@
const form = editor?.querySelector("[data-theme-form]"); const form = editor?.querySelector("[data-theme-form]");
if (!editor || !form) return; if (!editor || !form) return;
const root = document.documentElement; const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]"));
const originalScheme = root.dataset.colorScheme || "";
const tokenVariables = { const tokenVariables = {
bg1: "--bg-1", bg1: "--bg-1",
bg2: "--bg-2", bg2: "--bg-2",
@ -61,31 +60,45 @@
}); });
}; };
const applyPreview = () => { const buildPreviewVariables = () => {
root.dataset.colorScheme = previewMode; const variables = [];
form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => { form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => {
const variable = tokenVariables[input.dataset.themeToken]; 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) => { 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) => { form.querySelectorAll("[data-theme-metric]").forEach((input) => {
const config = metricVariables[input.dataset.themeMetric]; 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) => { form.querySelectorAll("[data-theme-font]").forEach((select) => {
const variable = typographyVariables[select.dataset.themeFont]; const variable = typographyVariables[select.dataset.themeFont];
const stack = select.selectedOptions[0]?.dataset.fontStack; 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) => { form.querySelectorAll("[data-theme-typography]").forEach((input) => {
const config = typographyVariables[input.dataset.themeTypography]; 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(); updateOutputs();
updateWarnings(); updateWarnings();
syncPopout(); syncPopout(variables);
}; };
const parseHex = (value) => { const parseHex = (value) => {
@ -133,28 +146,87 @@
); );
}; };
const getPreviewMarkup = () => editor.querySelector(".theme-preview-window")?.outerHTML || ""; const syncPopout = (variables = buildPreviewVariables()) => {
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 = () => {
if (!popout || popout.closed) return; if (!popout || popout.closed) return;
popout.document.documentElement.dataset.colorScheme = previewMode; popout.document.documentElement.dataset.colorScheme = previewMode;
currentPreviewVariables().forEach(([name, value]) => { variables.forEach(([name, value]) => {
popout.document.documentElement.style.setProperty(name, value); popout.document.documentElement.style.setProperty(name, value);
}); });
}; };
const previewShell = () => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumi Theme Preview</title>
<link rel="stylesheet" href="/lumi-tokens.css">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/lumi-layout.css">
<link rel="stylesheet" href="/lumi-components.css">
<link rel="stylesheet" href="/theme-editor.css">
<style>
body{margin:0;background:radial-gradient(circle at 16% 8%,var(--bg-1),transparent 38%),var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base);color:var(--lumi-text)}
.preview-app{display:grid;grid-template-columns:15rem minmax(0,1fr);min-height:100vh}
.preview-sidebar{padding:var(--lumi-space-4);border-right:1px solid var(--lumi-border);background:var(--lumi-surface)}
.preview-brand{display:flex;align-items:center;gap:var(--lumi-space-2);font:800 1rem var(--lumi-font-display)}
.preview-logo{width:2.4rem;height:2.4rem;display:grid;place-items:center;border-radius:var(--lumi-radius-sm);background:var(--lumi-primary);color:var(--lumi-button-text)}
.preview-nav{display:grid;gap:var(--lumi-space-2);margin-top:var(--lumi-space-5)}
.preview-nav a{padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm);color:var(--lumi-text);text-decoration:none}
.preview-nav a.active,.preview-nav a:hover{background:var(--lumi-surface-subtle);box-shadow:var(--lumi-shadow-sm)}
.preview-page{display:grid;gap:var(--lumi-space-4);align-content:start;padding:var(--lumi-space-5)}
.preview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-4)}
.preview-metric{display:grid;gap:var(--lumi-space-1)}
.preview-modal-sample{position:relative;min-height:9rem;display:grid;place-items:center;border:1px dashed var(--lumi-border);border-radius:var(--lumi-radius-md);background:color-mix(in srgb,var(--lumi-surface-subtle) 80%,transparent)}
.preview-modal-card{width:min(20rem,90%);padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface);box-shadow:var(--lumi-shadow-lg)}
@media (max-width:760px){.preview-app{grid-template-columns:1fr}.preview-sidebar{position:static;border-right:0;border-bottom:1px solid var(--lumi-border)}.preview-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="preview-app">
<aside class="preview-sidebar">
<div class="preview-brand"><span class="preview-logo">L</span><span>Lumi Bot</span></div>
<nav class="preview-nav" aria-label="Preview navigation">
<a class="active" href="#dashboard">Dashboard</a>
<a href="#settings">Settings</a>
<a href="#logs">Logs</a>
<a href="#ai">Lumi AI</a>
</nav>
</aside>
<main class="preview-page">
<section class="hero">
<span class="eyebrow">Faithful preview</span>
<h1>Community control center</h1>
<p>Draft theme values are isolated to this preview until you save and apply the theme.</p>
<div class="button-group"><button class="button">Primary action</button><button class="button subtle">Secondary</button></div>
</section>
<section class="preview-grid">
<article class="card preview-metric"><span class="hint">Messages</span><strong>12,480</strong><span class="status-success">Healthy stream</span></article>
<article class="card preview-metric"><span class="hint">Warnings</span><strong>3</strong><span class="status-warning">Needs review</span></article>
<article class="card">
<h2>Settings panel</h2>
<label>Channel title<input value="Cozy Carnage" readonly></label>
<label class="switch"><input class="switch-input" type="checkbox" checked><span class="switch-track"></span><span class="switch-text">Enabled toggle</span></label>
<div class="is-unsaved" style="padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm)">Unsaved marker sample</div>
</article>
<article class="card">
<h2>Logs table</h2>
<div class="table-wrap"><table class="table"><thead><tr><th>Level</th><th>Message</th></tr></thead><tbody><tr><td><span class="status-danger">error</span></td><td>Webhook retry failed</td></tr><tr><td><span class="status-success">info</span></td><td>Command synced</td></tr></tbody></table></div>
</article>
<article class="card">
<h2>State button</h2>
<button class="button lumi-state-btn"><span class="lumi-state-btn-content"><span data-state-view="idle">Download</span><span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner"></span>Downloading 64%</span><span data-state-view="success" data-state-hidden="true">Downloaded</span></span></button>
<p><span class="badge">Badge</span> <span class="pill">Preview pill</span></p>
</article>
<article class="card preview-modal-sample">
<div class="preview-modal-card"><h3>Modal sample</h3><p class="hint">Confirmation panels inherit theme surface, shadow, radius, and button tokens.</p><button class="button danger">Delete sample</button></div>
</article>
</section>
</main>
</div>
</body>
</html>`;
form.addEventListener("input", applyPreview); form.addEventListener("input", applyPreview);
form.addEventListener("change", applyPreview); form.addEventListener("change", applyPreview);
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => { editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
@ -188,28 +260,11 @@
return; return;
} }
popout.document.open(); popout.document.open();
popout.document.write(`<!doctype html> popout.document.write(previewShell());
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumi Theme Preview</title>
<link rel="stylesheet" href="/lumi-tokens.css">
<link rel="stylesheet" href="/lumi-components.css">
<link rel="stylesheet" href="/theme-editor.css">
<style>body{margin:0;padding:1rem;background:var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base)}.theme-preview-window{width:100%;min-height:calc(100vh - 2rem)}</style>
</head>
<body>${getPreviewMarkup()}</body>
</html>`);
popout.document.close(); popout.document.close();
if (status) status.textContent = "Pop-out preview is open and updates with this editor."; if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
syncPopout(); syncPopout();
}); });
window.addEventListener("beforeunload", () => {
if (originalScheme) root.dataset.colorScheme = originalScheme;
else delete root.dataset.colorScheme;
});
applyPreview(); applyPreview();
})(); })();

View File

@ -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) => { app.get("/admin/settings", requireRole("admin"), (req, res) => {
res.render("admin-settings", { res.render("admin-settings", {
title: "Settings", title: "Settings",

View File

@ -43,6 +43,33 @@
</div> </div>
</div> </div>
</section> </section>
<section class="card admin-metrics" data-dashboard-metrics>
<div class="section-header">
<div>
<h2>Live metrics</h2>
<p class="hint">Process health, content counts, plugin status, and recent log severity.</p>
</div>
<span class="status-indicator status-success" data-metrics-status>Loading</span>
</div>
<div class="dashboard-metric-grid">
<div><span>Uptime</span><strong data-metric="uptime">-</strong></div>
<div><span>Memory RSS</span><strong data-metric="rss">-</strong></div>
<div><span>Heap used</span><strong data-metric="heap">-</strong></div>
<div><span>Plugins enabled</span><strong data-metric="plugins">-</strong></div>
<div><span>Users</span><strong data-metric="users">-</strong></div>
<div><span>Commands</span><strong data-metric="commands">-</strong></div>
</div>
<div class="dashboard-chart-grid">
<figure class="dashboard-chart-card">
<figcaption>Memory trend</figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Memory trend" data-memory-chart></svg>
</figure>
<figure class="dashboard-chart-card">
<figcaption>Recent logs by severity</figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Recent logs by severity" data-log-chart></svg>
</figure>
</div>
</section>
<section class="card"> <section class="card">
<h2>Maintenance</h2> <h2>Maintenance</h2>
<div class="button-group"> <div class="button-group">
@ -57,6 +84,7 @@
</form> </form>
</div> </div>
</section> </section>
<script src="/dashboard.js?v=<%= assetVersion %>" defer></script>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -7,13 +7,18 @@
<p class="command-subtitle">Core system logs with severity, timestamps, and details.</p> <p class="command-subtitle">Core system logs with severity, timestamps, and details.</p>
</div> </div>
<div class="log-controls"> <div class="log-controls">
<input <label>
class="table-search" <span>Search</span>
type="search" <input
placeholder="Search logs" class="table-search"
aria-label="Search logs" type="search"
data-log-search placeholder="Search logs"
/> aria-label="Search logs"
data-log-search
/>
</label>
<label>
<span>Severity</span>
<select class="table-search" data-log-level aria-label="Filter log severity"> <select class="table-search" data-log-level aria-label="Filter log severity">
<option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</option> <option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</option>
<option value="error" <%= filters.level === 'error' ? 'selected' : '' %>>Error</option> <option value="error" <%= filters.level === 'error' ? 'selected' : '' %>>Error</option>
@ -21,6 +26,9 @@
<option value="info" <%= filters.level === 'info' ? 'selected' : '' %>>Info</option> <option value="info" <%= filters.level === 'info' ? 'selected' : '' %>>Info</option>
<option value="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option> <option value="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option>
</select> </select>
</label>
<label>
<span>Range</span>
<select class="table-search" data-log-range aria-label="Filter by time range"> <select class="table-search" data-log-range aria-label="Filter by time range">
<option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option> <option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option>
<option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option> <option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option>
@ -28,12 +36,18 @@
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option> <option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option>
<option value="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option> <option value="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option>
</select> </select>
</label>
<label>
<span>Entries</span>
<select class="table-search" data-log-limit aria-label="Limit log entries"> <select class="table-search" data-log-limit aria-label="Limit log entries">
<option value="50" <%= filters.limit === '50' ? 'selected' : '' %>>50 most recent</option> <option value="50" <%= filters.limit === '50' ? 'selected' : '' %>>50 most recent</option>
<option value="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 most recent</option> <option value="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 most recent</option>
<option value="250" <%= filters.limit === '250' ? 'selected' : '' %>>250 most recent</option> <option value="250" <%= filters.limit === '250' ? 'selected' : '' %>>250 most recent</option>
<option value="500" <%= filters.limit === '500' ? 'selected' : '' %>>500 most recent</option> <option value="500" <%= filters.limit === '500' ? 'selected' : '' %>>500 most recent</option>
</select> </select>
</label>
<a class="button subtle" href="/admin/logs">Reset</a>
<a class="button subtle" href="<%= `/admin/logs?range=${encodeURIComponent(filters.range)}&level=${encodeURIComponent(filters.level)}&limit=${encodeURIComponent(filters.limit)}` %>">Refresh</a>
<button type="button" class="button subtle" data-log-download>Download logs</button> <button type="button" class="button subtle" data-log-download>Download logs</button>
</div> </div>
</div> </div>

View File

@ -47,10 +47,10 @@
<section class="card"> <section class="card">
<h2>Install plugin from ZIP</h2> <h2>Install plugin from ZIP</h2>
<form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid">
<div class="field full"> <div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required /> <input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin</button>
</div> </div>
<button type="submit" class="button">Upload plugin</button>
</form> </form>
</section> </section>
<section class="card"> <section class="card">

View File

@ -115,17 +115,33 @@
<div class="field full"> <div class="field full">
<h2>Homepage content</h2> <h2>Homepage content</h2>
<p class="hint">Configure public homepage link buttons and the priority-based dynamic hero. Use JSON arrays; invalid JSON is rejected without saving.</p> <p class="hint">Configure public homepage link buttons and the priority-based dynamic hero without editing raw JSON.</p>
</div> </div>
<div class="field full"> <div class="field full homepage-builder" data-homepage-builder="links">
<label>Homepage link buttons JSON</label> <div class="section-header">
<textarea name="homepage_link_buttons" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea> <div>
<p class="hint">Fields: enabled, label, description, url, icon_url, permission public/user/mod/admin, sort_order.</p> <h3>Homepage link buttons</h3>
<p class="hint">Add public or role-limited links shown as cards on the homepage.</p>
</div>
<button type="button" class="button subtle" data-homepage-add="links">Add link</button>
</div>
<textarea name="homepage_link_buttons" class="homepage-json-source" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea>
<div class="homepage-builder-list" data-homepage-list="links"></div>
</div> </div>
<div class="field full"> <div class="field full homepage-builder" data-homepage-builder="heroes">
<label>Homepage hero entries JSON</label> <div class="section-header">
<textarea name="homepage_hero_entries" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea> <div>
<p class="hint">Fields: enabled, type, title, description, priority, permission, source_url, image_url, embed_url, video_id, availability_mode, autoplay_mode, duration_seconds.</p> <h3>Homepage hero entries</h3>
<p class="hint">The first available enabled hero by priority is shown on the homepage.</p>
</div>
<button type="button" class="button subtle" data-homepage-add="heroes">Add hero</button>
</div>
<textarea name="homepage_hero_entries" class="homepage-json-source" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea>
<div class="homepage-builder-list" data-homepage-list="heroes"></div>
<details class="advanced-theme-controls">
<summary>Advanced JSON</summary>
<p class="hint">The builder writes JSON into hidden fields before save. Edit only if you need a field the builder does not expose.</p>
</details>
</div> </div>
<button type="submit" class="button">Save settings</button> <button type="submit" class="button">Save settings</button>
@ -147,16 +163,19 @@
<div class="nav-icon-actions"> <div class="nav-icon-actions">
<form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form"> <form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form">
<input type="hidden" name="item_id" value="<%= item.id %>" /> <input type="hidden" name="item_id" value="<%= item.id %>" />
<input type="file" name="icon_file" accept="image/svg+xml,image/png" /> <div class="input-action-row">
<button type="submit" class="button subtle">Upload</button> <input type="file" name="icon_file" accept="image/svg+xml,image/png" />
</form> <button type="submit" class="button subtle">Upload</button>
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form"> </div>
<input type="hidden" name="item_id" value="<%= item.id %>" /> </form>
<button type="submit" class="button subtle">Reset</button> <form method="post" action="/admin/settings/nav-icons/reset" class="inline-form" data-confirm-title="Reset navigation icon" data-confirm-text="Reset the custom icon for <%= item.label %> to its default." data-confirm-label="Reset icon">
</form> <input type="hidden" name="item_id" value="<%= item.id %>" />
<button type="submit" class="button subtle">Reset</button>
</form>
</div> </div>
</div> </div>
<% }) %> <% }) %>
</div> </div>
</section> </section>
<%- include("partials/layout-bottom") %> <script src="/homepage-builder.js?v=<%= assetVersion %>" defer></script>
<%- include("partials/layout-bottom") %>

View File

@ -132,6 +132,7 @@
action="/admin/theming/custom/<%= customId %>/delete" action="/admin/theming/custom/<%= customId %>/delete"
data-confirm-title="Delete custom theme" data-confirm-title="Delete custom theme"
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected." data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
data-confirm-label="Delete theme"
> >
<button type="submit" class="button danger">Delete</button> <button type="submit" class="button danger">Delete</button>
</form> </form>
@ -155,6 +156,7 @@
<div class="button-group" aria-label="Preview color scheme"> <div class="button-group" aria-label="Preview color scheme">
<button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button> <button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button>
<button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button> <button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button>
<button type="button" class="button subtle theme-mobile-preview-action" data-theme-popout>Preview</button>
</div> </div>
</div> </div>
@ -313,27 +315,56 @@
<span class="eyebrow">Live preview</span> <span class="eyebrow">Live preview</span>
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button> <button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
</div> </div>
<div class="theme-preview-window"> <div class="theme-preview-window" data-theme-preview-root>
<div class="theme-preview-nav"> <div class="theme-preview-nav">
<span class="theme-preview-logo">L</span> <span class="theme-preview-logo">L</span>
<span></span><span></span><span></span> <span class="is-active"></span><span></span><span></span>
</div> </div>
<div class="theme-preview-content"> <div class="theme-preview-content">
<div class="theme-preview-heading"></div> <span class="eyebrow theme-preview-pill">Preview pill</span>
<div class="theme-preview-lines"><span></span><span></span></div> <h2>Theme controlled heading</h2>
<p class="hint">Typography, spacing, colors, radius, shadows, and state tokens update only inside this preview.</p>
<div class="theme-preview-sample-card"> <div class="theme-preview-sample-card">
<strong>Community overview</strong> <strong>Community overview</strong>
<p>Preview text, surfaces, borders, and controls before saving.</p> <p>Preview text, surfaces, borders, and controls before saving.</p>
<div class="button-group"> <div class="button-group">
<span class="button">Primary</span> <span class="button">Primary</span>
<span class="button subtle">Secondary</span> <span class="button subtle">Secondary</span>
<span class="button lumi-state-btn">
<span class="lumi-state-btn-content">
<span data-state-view="idle">Download</span>
<span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner" aria-hidden="true"></span>Downloading</span>
<span data-state-view="success" data-state-hidden="true">Downloaded</span>
</span>
</span>
</div> </div>
<input value="Input preview" readonly aria-label="Input preview" /> <input value="Input preview" readonly aria-label="Input preview" />
<label class="switch">
<input class="switch-input" type="checkbox" checked />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Toggle sample</span>
</label>
<div class="theme-preview-statuses"> <div class="theme-preview-statuses">
<span class="status-success">Success</span> <span class="status-success">Success</span>
<span class="status-warning">Warning</span> <span class="status-warning">Warning</span>
<span class="status-danger">Danger</span> <span class="status-danger">Danger</span>
</div> </div>
<div class="theme-preview-badges">
<span class="badge">Badge</span>
<span class="pill">Role <span style="color:var(--role-admin)">admin</span></span>
</div>
<div class="is-unsaved theme-preview-dirty">Dirty state marker</div>
<div class="table-wrap theme-preview-table">
<table class="table">
<thead><tr><th>Level</th><th>Message</th></tr></thead>
<tbody><tr><td>Info</td><td>Command synced</td></tr><tr><td>Warn</td><td>Queue growing</td></tr></tbody>
</table>
</div>
<div class="theme-preview-modal-sample">
<strong>Modal sample</strong>
<p class="hint">Radius, border, surface, and shadow.</p>
</div>
<div class="theme-spacing-sample"><span></span><span></span><span></span></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -24,9 +24,10 @@
<section class="card"> <section class="card">
<h2>Upload bot update</h2> <h2>Upload bot update</h2>
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
<div class="field full"> <div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required /> <input type="file" name="update_zip" accept=".zip" required />
</div> <button type="submit" class="button">Upload bot update</button>
</div>
<div class="field full"> <div class="field full">
<label>Patch mode (apply only files in ZIP, skip full package verification)</label> <label>Patch mode (apply only files in ZIP, skip full package verification)</label>
<label class="switch"> <label class="switch">
@ -35,21 +36,16 @@
<span class="switch-text">Patch mode</span> <span class="switch-text">Patch mode</span>
</label> </label>
</div> </div>
<div class="field full"> </form>
<button type="submit" class="button">Upload bot update</button> </section>
</div>
</form>
</section>
<section class="card"> <section class="card">
<h2>Upload plugin update</h2> <h2>Upload plugin update</h2>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full"> <div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required /> <input type="file" name="plugin_zip" accept=".zip" required />
</div> <button type="submit" class="button">Upload plugin update</button>
<div class="field full"> </div>
<button type="submit" class="button">Upload plugin update</button>
</div>
</form> </form>
</section> </section>

View File

@ -27,7 +27,7 @@
> >
<span class="lumi-state-btn-content"> <span class="lumi-state-btn-content">
<% stateButtonStates.forEach((state) => { %> <% stateButtonStates.forEach((state) => { %>
<span data-state-view="<%= state.id %>" <%= state.id === stateButtonDefault ? "" : "hidden" %>> <span data-state-view="<%= state.id %>" data-state-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>" aria-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>">
<% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %> <% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %>
<span><%= state.text %></span> <span><%= state.text %></span>
</span> </span>