ui: complete webui corrective pass
This commit is contained in:
parent
0d4431924a
commit
b3a499536f
@ -81,10 +81,17 @@ presets plus bounded base-size, heading-scale, and control-density ranges. The
|
||||
server accepts only six-digit hex colors, supported font presets, bounded metric
|
||||
values, and readable text/button/input contrast.
|
||||
|
||||
The live preview updates colors, role colors, metrics, and typography before
|
||||
save. The editor also shows contrast warnings for the current preview mode,
|
||||
offers a reset-to-base action for inherited custom themes, and provides an
|
||||
optional desktop pop-out preview window that stays synchronized with the editor.
|
||||
Draft values are isolated to preview roots. The compact preview and pop-out
|
||||
preview update colors, role colors, metrics, spacing, and typography before
|
||||
save, but the editor shell and live site keep the active saved theme until the
|
||||
admin saves/applies the custom theme. The compact preview is hidden on narrow
|
||||
phone layouts; use the Preview action to open the synchronized pop-out instead.
|
||||
|
||||
The compact preview includes representative headings, pills, cards, buttons,
|
||||
state buttons, inputs, toggles, alerts/statuses, badges, tables, modal samples,
|
||||
dirty-state markers, and spacing samples. The pop-out uses a faithful Lumi page
|
||||
shell with safe sample data so admins can test draft themes against dashboard,
|
||||
settings, logs, AI, and component-like layouts without exposing real admin data.
|
||||
|
||||
Missing or invalid stored values are replaced from the custom theme's built-in
|
||||
base. Existing installations with modified legacy `theme_light_*`,
|
||||
@ -112,32 +119,52 @@ password field blank keeps the existing password.
|
||||
|
||||
Lumi AI's main Selected model dropdown lists only installed/downloaded models.
|
||||
If the configured model is missing, the settings page shows a warning and saving
|
||||
requires selecting an installed model. Main context size is a preset dropdown:
|
||||
Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported
|
||||
freeform context values are rejected server-side.
|
||||
requires selecting an installed model. Main context, gate context, and output
|
||||
token budgets use shared presets from Tiny (256) through Extra extended
|
||||
(32768). Unsupported freeform values are rejected server-side.
|
||||
|
||||
AI feedback supports `feedback_kind` values `strict_correction` and
|
||||
`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where
|
||||
the model called the wrong tool or failed to call an expected tool. Review,
|
||||
edit, and implementation views show both the kind and tag so admins can tell
|
||||
direct answer corrections from broader tool-calling or instruction guidance.
|
||||
`instruction_based`; instruction-based feedback is the default because most
|
||||
reviews are guidance for future replies rather than exact replacement answers.
|
||||
Feedback tags include `wrong_tool_usage` for cases where the model called the
|
||||
wrong tool or failed to call an expected tool. Review, edit, and implementation
|
||||
views show both the kind and tag so admins can tell direct answer corrections
|
||||
from broader tool-calling or instruction guidance.
|
||||
|
||||
Model/runtime downloads and the combined Start/Restart runtime control use the
|
||||
Lumi state button behavior. Enhanced browsers start downloads and runtime
|
||||
actions through fetch, update button state/progress in place, and avoid hard
|
||||
page refreshes; the underlying POST routes remain available for non-JavaScript
|
||||
fallbacks.
|
||||
|
||||
## Homepage Content
|
||||
|
||||
Admins can define homepage external link buttons in `homepage_link_buttons` from
|
||||
Admin > Settings. Each entry may include `enabled`, `label`, `description`,
|
||||
`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and
|
||||
`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are
|
||||
filtered server-side by permission.
|
||||
Admins configure homepage external link buttons from Admin > Settings with the
|
||||
Homepage content builder. It writes the existing `homepage_link_buttons` JSON
|
||||
setting behind the scenes. Each entry may include `enabled`, `label`,
|
||||
`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`,
|
||||
`admin`), and `sort_order`. Links open in a new tab with
|
||||
`rel="noopener noreferrer"` and are filtered server-side by permission.
|
||||
|
||||
Admins can define priority-based hero entries in `homepage_hero_entries`.
|
||||
Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`,
|
||||
`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and
|
||||
`none`. The homepage renders the first enabled, available entry the current user
|
||||
can access. Hero entries support priority/order, permission, source/embed/image
|
||||
URLs, video IDs, availability mode, autoplay mode metadata, and duration fields.
|
||||
Slow external availability checks are intentionally avoided; entries fail
|
||||
closed if required local configuration is missing.
|
||||
Admins configure priority-based hero entries with the same builder; it writes
|
||||
the existing `homepage_hero_entries` JSON setting behind the scenes. The
|
||||
homepage renders the first enabled, available entry the current user can access.
|
||||
Hero entries support type, priority/order, permission, source/embed/image URLs,
|
||||
video IDs, availability mode, autoplay mode metadata, and duration fields. Slow
|
||||
external availability checks are intentionally avoided; entries fail closed if
|
||||
required local configuration is missing.
|
||||
|
||||
## Admin Dashboard And Logs
|
||||
|
||||
The admin dashboard polls `GET /api/admin/dashboard-metrics` for process
|
||||
uptime, memory, plugin counts, content counts, and recent log severity totals.
|
||||
The dashboard renders lightweight SVG graphs using Lumi tokens and does not add
|
||||
a frontend framework dependency.
|
||||
|
||||
The logs page keeps server-side range/severity/limit filters and adds a labeled
|
||||
responsive filter bar with search, reset, refresh, and download actions. Search
|
||||
filters the loaded entries client-side; changing range, severity, or limit
|
||||
reloads the same `/admin/logs` route with query parameters.
|
||||
|
||||
## Visual references
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ const FEEDBACK_TAGS = Object.freeze([
|
||||
"wrong_tool_usage"
|
||||
]);
|
||||
|
||||
const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]);
|
||||
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
|
||||
|
||||
class FeedbackStore {
|
||||
constructor(options = {}) {
|
||||
@ -39,7 +39,7 @@ class FeedbackStore {
|
||||
feedback_tag: tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
|
||||
? input.feedback_kind
|
||||
: "strict_correction",
|
||||
: "instruction_based",
|
||||
optional_correction: clean(input.optional_correction, 16000),
|
||||
status: "pending",
|
||||
submitted_by: String(actor?.id || "anonymous"),
|
||||
@ -76,7 +76,7 @@ class FeedbackStore {
|
||||
return this.mutate(id, (entry) => ({
|
||||
...entry,
|
||||
feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction",
|
||||
feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "instruction_based",
|
||||
optional_correction: clean(values.optional_correction, 16000),
|
||||
review_notes: clean(values.review_notes, 4000),
|
||||
reviewed_by: String(actor.id),
|
||||
|
||||
@ -39,12 +39,18 @@ const storage = require("./backend/storage");
|
||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
||||
|
||||
const PLUGIN_ID = "lumi_ai";
|
||||
const CONTEXT_OPTIONS = Object.freeze([
|
||||
{ label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." },
|
||||
const TOKEN_PRESETS = Object.freeze([
|
||||
{ label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." },
|
||||
{ label: "Very small (512)", value: 512, description: "Short replies and low memory usage." },
|
||||
{ label: "Small (1024)", value: 1024, description: "Compact answers and lightweight request gates." },
|
||||
{ label: "Short (2048)", value: 2048, description: "Short conversations and normal commands." },
|
||||
{ label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." },
|
||||
{ label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." },
|
||||
{ label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." }
|
||||
{ label: "Large (8192)", value: 8192, description: "Longer conversations and documents." },
|
||||
{ label: "Extended (16384)", value: 16384, description: "Long context when the selected model supports it." },
|
||||
{ label: "Extra extended (32768)", value: 32768, description: "Highest supported local preset for large-context models." }
|
||||
]);
|
||||
const CONTEXT_OPTIONS = TOKEN_PRESETS;
|
||||
const GATE_CONTEXT_OPTIONS = TOKEN_PRESETS.filter((option) => option.value >= 512 && option.value <= 4096);
|
||||
const modelManifest = require("./models_manifest.json");
|
||||
const runtimeManifest = require("./runtime_manifest.json");
|
||||
|
||||
@ -322,6 +328,8 @@ module.exports = {
|
||||
installedModels,
|
||||
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
|
||||
contextOptions: CONTEXT_OPTIONS,
|
||||
tokenPresets: TOKEN_PRESETS,
|
||||
gateContextOptions: GATE_CONTEXT_OPTIONS,
|
||||
runtimeTarget,
|
||||
runtimeManifest,
|
||||
runtimeStatus,
|
||||
@ -371,8 +379,23 @@ module.exports = {
|
||||
return flash(req, res, "error", "Choose a supported AI context size.");
|
||||
}
|
||||
const contextSize = requestedContext;
|
||||
const tokenValues = TOKEN_PRESETS.map((option) => option.value);
|
||||
const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value);
|
||||
const requestedGateContext = Number(req.body.gate_context_size);
|
||||
if (!gateContextValues.includes(requestedGateContext)) {
|
||||
return flash(req, res, "error", "Choose a supported gate context size.");
|
||||
}
|
||||
const presetToken = (field, fallback, label) => {
|
||||
const value = Number(req.body[field]);
|
||||
if (!tokenValues.includes(value)) {
|
||||
throw new Error(`Choose a supported preset for ${label}.`);
|
||||
}
|
||||
return value || fallback;
|
||||
};
|
||||
const previousConfig = config;
|
||||
config = saveConfig({
|
||||
let nextConfig;
|
||||
try {
|
||||
nextConfig = {
|
||||
...config,
|
||||
enabled: req.body.enabled === "on",
|
||||
selected_model_id: model.id,
|
||||
@ -385,13 +408,13 @@ module.exports = {
|
||||
request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
||||
ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000),
|
||||
hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
||||
max_output_tokens: boundedInt(req.body.max_output_tokens, 64, 32768, 2048),
|
||||
max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"),
|
||||
output_budgets: {
|
||||
navigation_help: boundedInt(req.body.output_budget_navigation_help, 64, 32768, 256),
|
||||
simple_answer: boundedInt(req.body.output_budget_simple_answer, 64, 32768, 512),
|
||||
code_custom_command: boundedInt(req.body.output_budget_code_custom_command, 64, 32768, 896),
|
||||
admin_debug: boundedInt(req.body.output_budget_admin_debug, 64, 32768, 1280),
|
||||
explicit_long: boundedInt(req.body.output_budget_explicit_long, 64, 32768, 2048)
|
||||
navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"),
|
||||
simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"),
|
||||
code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"),
|
||||
admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"),
|
||||
explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens")
|
||||
},
|
||||
batch_size: boundedInt(req.body.batch_size, 32, 4096, 512),
|
||||
ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128),
|
||||
@ -443,7 +466,7 @@ module.exports = {
|
||||
gate: {
|
||||
...config.gate,
|
||||
model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id,
|
||||
context_size: boundedInt(req.body.gate_context_size, 512, 4096, 1024),
|
||||
context_size: requestedGateContext,
|
||||
threads: boundedInt(req.body.gate_threads, 1, 16, 2),
|
||||
timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000),
|
||||
high_confidence_threshold: boundedNumber(req.body.gate_high_confidence_threshold, 0.5, 0.99, 0.88),
|
||||
@ -489,7 +512,11 @@ module.exports = {
|
||||
trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers),
|
||||
corrections_enabled: req.body.corrections_enabled === "on"
|
||||
}
|
||||
});
|
||||
};
|
||||
} catch (error) {
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
config = saveConfig(nextConfig);
|
||||
registerAssistantCommands({
|
||||
commandRouter,
|
||||
provider,
|
||||
@ -525,11 +552,15 @@ module.exports = {
|
||||
|
||||
router.post("/download/runtime", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const wantsJson = req.accepts(["json", "html"]) === "json";
|
||||
const hardware = detectHardware(modelManifest.models, runtimeManifest);
|
||||
const target = getRuntimeTarget(hardware);
|
||||
if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
||||
if (!target) {
|
||||
if (wantsJson) return res.status(400).json({ error: "No managed llama.cpp runtime is available for this platform." });
|
||||
return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
||||
}
|
||||
try {
|
||||
downloads.start({
|
||||
const job = downloads.start({
|
||||
id: "runtime",
|
||||
...target,
|
||||
kind: "runtime",
|
||||
@ -540,29 +571,37 @@ module.exports = {
|
||||
target: target.filename
|
||||
}
|
||||
});
|
||||
if (wantsJson) return res.json({ success: true, job });
|
||||
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`);
|
||||
} catch (error) {
|
||||
if (wantsJson) return res.status(400).json({ error: error.message });
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/download/model/:id", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const wantsJson = req.accepts(["json", "html"]) === "json";
|
||||
const model = getModel(req.params.id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
if (!model) {
|
||||
if (wantsJson) return res.status(404).json({ error: "Unknown model." });
|
||||
return flash(req, res, "error", "Unknown model.");
|
||||
}
|
||||
if (
|
||||
(model.id === config.selected_model_id && runtime.status().state === "running") ||
|
||||
(model.id === config.gate.model_id && gateRuntime.status().state === "running")
|
||||
) {
|
||||
if (wantsJson) return res.status(400).json({ error: "Stop the AI runtimes before replacing an active model." });
|
||||
return flash(req, res, "error", "Stop the AI runtimes before replacing an active model.");
|
||||
}
|
||||
const hardware = detectHardware(modelManifest.models);
|
||||
const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb;
|
||||
if (incompatible && req.body.override_compatibility !== "on") {
|
||||
if (wantsJson) return res.status(400).json({ error: "This model exceeds detected RAM or free disk. Check override to download anyway." });
|
||||
return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
|
||||
}
|
||||
try {
|
||||
downloads.start({
|
||||
const job = downloads.start({
|
||||
id: `model:${model.id}`,
|
||||
url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`,
|
||||
filename: model.filename,
|
||||
@ -570,8 +609,10 @@ module.exports = {
|
||||
size: model.size,
|
||||
kind: "model"
|
||||
});
|
||||
if (wantsJson) return res.json({ success: true, job });
|
||||
return flash(req, res, "success", `${model.label} download started.`);
|
||||
} catch (error) {
|
||||
if (wantsJson) return res.status(400).json({ error: error.message });
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
@ -300,8 +300,8 @@
|
||||
const kind = document.createElement("select");
|
||||
kind.setAttribute("aria-label", "Feedback type");
|
||||
for (const [value, label] of [
|
||||
["strict_correction", "Strict correction"],
|
||||
["instruction_based", "Instruction-based guidance"]
|
||||
["instruction_based", "Instruction-based guidance"],
|
||||
["strict_correction", "Strict correction"]
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
|
||||
@ -7,27 +7,66 @@
|
||||
const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]");
|
||||
const gpuControl = document.querySelector("[data-gpu-control]");
|
||||
const accessForm = document.querySelector("[data-ai-access-form]");
|
||||
const runtimePrimary = document.querySelector("[data-runtime-primary]");
|
||||
if (actions) {
|
||||
const syncPrimary = (nextState) => {
|
||||
if (!runtimePrimary || !window.LumiStateButton) return;
|
||||
const normalized = String(nextState || state?.textContent || "").toLowerCase();
|
||||
window.LumiStateButton.setState(runtimePrimary, normalized === "running" ? "running" : "idle");
|
||||
};
|
||||
actions.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-runtime-action]");
|
||||
const primaryButton = event.target.closest("[data-runtime-primary]");
|
||||
const button = primaryButton || event.target.closest("[data-runtime-action]");
|
||||
if (!button) return;
|
||||
const action = primaryButton
|
||||
? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start")
|
||||
: button.dataset.runtimeAction;
|
||||
if (!action) return;
|
||||
button.disabled = true;
|
||||
if (primaryButton && window.LumiStateButton) {
|
||||
window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true });
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" });
|
||||
const response = await fetch(`/plugins/lumi_ai/runtime/${action}`, { method: "POST" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Runtime action failed.");
|
||||
if (data.state) state.textContent = data.state;
|
||||
if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(button.dataset.runtimeAction)) {
|
||||
syncPrimary(data.state);
|
||||
if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(action)) {
|
||||
const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed.", "verify-gate-model": "Gate model verification passed." };
|
||||
window.alert(labels[button.dataset.runtimeAction]);
|
||||
window.alert(labels[action]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("[data-ai-download-form]").forEach((form) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = form.querySelector("[data-ai-download-button]");
|
||||
window.LumiStateButton?.setState(button, "loading", { busy: true });
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json" },
|
||||
body: new FormData(form)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Download failed to start.");
|
||||
pollDownloads();
|
||||
} catch (error) {
|
||||
window.LumiStateButton?.error(button);
|
||||
if (downloadStatus) {
|
||||
downloadStatus.hidden = false;
|
||||
downloadStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const pollDownloads = async () => {
|
||||
if (!downloadStatus) return;
|
||||
try {
|
||||
@ -41,9 +80,19 @@
|
||||
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
|
||||
return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`;
|
||||
}).join(" | ");
|
||||
jobs.forEach((job) => {
|
||||
const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`);
|
||||
if (!button || !window.LumiStateButton) return;
|
||||
if (job.state === "complete") window.LumiStateButton.setState(button, "success");
|
||||
else if (job.state === "error") window.LumiStateButton.setState(button, "error");
|
||||
else window.LumiStateButton.setState(button, "loading", { busy: true });
|
||||
const label = button.querySelector('[data-state-view="loading"] span:last-child');
|
||||
if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`;
|
||||
});
|
||||
if (active.length) window.setTimeout(pollDownloads, 1000);
|
||||
} catch {}
|
||||
};
|
||||
pollDownloads();
|
||||
if (testForm && testOutput) {
|
||||
const updateTestToolsNotice = () => {
|
||||
if (!testToolsNotice) return;
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form">
|
||||
<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 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>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>
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<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">
|
||||
<div>
|
||||
@ -70,19 +82,39 @@
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify">
|
||||
<button class="button subtle" type="submit">Verify</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>">
|
||||
<button class="button subtle" type="submit">Redownload</button>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
|
||||
<%- 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 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" />
|
||||
<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>
|
||||
<% } 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) { %>
|
||||
<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>
|
||||
<% } %>
|
||||
</div>
|
||||
@ -95,12 +127,25 @@
|
||||
<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 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="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-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>
|
||||
</div>
|
||||
</div>
|
||||
@ -138,8 +183,18 @@
|
||||
<% if (runtimeTarget) { %>
|
||||
<p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
|
||||
<p class="hint"><%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %></p>
|
||||
<form method="post" action="/plugins/lumi_ai/download/runtime">
|
||||
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button>
|
||||
<form method="post" action="/plugins/lumi_ai/download/runtime" data-ai-download-form data-download-id="runtime">
|
||||
<%- 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>
|
||||
<% } else { %>
|
||||
<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>
|
||||
<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="runtime_archives" /> Runtime archives</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>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>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>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>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>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>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>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>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><select name="output_budget_navigation_help"><%- renderPresetOptions(tokenPresets, config.output_budgets.navigation_help) %></select></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><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><select name="output_budget_admin_debug"><%- renderPresetOptions(tokenPresets, config.output_budgets.admin_debug) %></select></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>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>
|
||||
@ -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>
|
||||
<span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span>
|
||||
</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 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>
|
||||
@ -482,7 +537,7 @@
|
||||
<input type="hidden" name="source" value="local" />
|
||||
<button class="button subtle" type="submit">Refresh local</button>
|
||||
</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" />
|
||||
<button class="button subtle" type="submit">Refresh public</button>
|
||||
</form>
|
||||
@ -578,7 +633,7 @@
|
||||
<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) %>/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>
|
||||
</tr>
|
||||
<% }) %>
|
||||
|
||||
@ -33,6 +33,21 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".nav-section").forEach((section) => {
|
||||
const summary = section.querySelector("summary");
|
||||
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
||||
section.addEventListener("toggle", () => {
|
||||
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
||||
if (!section.open) return;
|
||||
document.querySelectorAll(".nav-section[open]").forEach((other) => {
|
||||
if (other !== section) {
|
||||
other.open = false;
|
||||
other.querySelector("summary")?.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
media.addEventListener?.("change", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
if (media.matches) {
|
||||
@ -531,6 +546,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
const actionCopy = (action) => {
|
||||
const normalized = String(action || "").toLowerCase();
|
||||
if (normalized.includes("/delete")) return { title: "Confirm deletion", label: "Delete" };
|
||||
if (normalized.includes("/uninstall")) return { title: "Confirm uninstall", label: "Uninstall" };
|
||||
if (normalized.includes("/cleanup")) return { title: "Confirm cleanup", label: "Clean selected" };
|
||||
if (normalized.includes("/reset")) return { title: "Confirm reset", label: "Reset" };
|
||||
if (normalized.includes("/remove")) return { title: "Confirm removal", label: "Remove" };
|
||||
if (normalized.includes("/update")) return { title: "Confirm update", label: "Update" };
|
||||
if (normalized.includes("/restart")) return { title: "Confirm restart", label: "Restart" };
|
||||
return { title: "Confirm action", label: "Confirm" };
|
||||
};
|
||||
|
||||
const isDestructiveForm = (form) => {
|
||||
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
|
||||
return String(form.method || "get").toLowerCase() === "post" &&
|
||||
@ -567,12 +594,14 @@
|
||||
form.requestSubmit(submitter?.form === form ? submitter : undefined);
|
||||
};
|
||||
|
||||
const confirmLabel = (form) => form.dataset.confirmLabel || actionCopy(destructiveAction(form)).label;
|
||||
|
||||
const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
|
||||
const state = destructiveStates.get(form) || {};
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
|
||||
button.disabled = remaining > 0;
|
||||
button.textContent = remaining > 0 ? `Confirm in ${remaining}` : "Confirm";
|
||||
button.textContent = remaining > 0 ? `${confirmLabel(form)} in ${remaining}` : confirmLabel(form);
|
||||
if (!remaining && state.timer) {
|
||||
window.clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
@ -592,14 +621,15 @@
|
||||
const action = destructiveAction(form);
|
||||
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
|
||||
destructiveStates.set(form, state);
|
||||
const message = form.dataset.confirmText || "This action cannot be undone.";
|
||||
const copy = actionCopy(action);
|
||||
const message = form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
|
||||
const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
|
||||
let confirmButton;
|
||||
|
||||
if (mode === "modal" && destructiveModal && destructiveConfirm) {
|
||||
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
||||
activeDestructive = { form };
|
||||
destructiveTitle.textContent = form.dataset.confirmTitle || "Confirm destructive action";
|
||||
destructiveTitle.textContent = form.dataset.confirmTitle || copy.title;
|
||||
destructiveDescription.textContent = message;
|
||||
destructiveConfirm.disabled = true;
|
||||
destructiveConfirm.textContent = "Preparing...";
|
||||
@ -678,6 +708,7 @@
|
||||
form.dataset.syntheticConfirmation = "true";
|
||||
form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action";
|
||||
form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone.";
|
||||
form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm";
|
||||
document.body.append(form);
|
||||
issueDestructiveConfirmation(form, null);
|
||||
}, true);
|
||||
|
||||
82
src/web/public/dashboard.js
Normal file
82
src/web/public/dashboard.js
Normal 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);
|
||||
})();
|
||||
203
src/web/public/homepage-builder.js
Normal file
203
src/web/public/homepage-builder.js
Normal 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();
|
||||
});
|
||||
})();
|
||||
@ -182,6 +182,8 @@ section.card:has(> table.table) {
|
||||
button.button,
|
||||
input[type="submit"].button {
|
||||
min-height: var(--lumi-control-height);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -246,6 +248,7 @@ button:disabled {
|
||||
|
||||
.lumi-state-btn {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.lumi-state-btn[aria-busy="true"] {
|
||||
@ -253,6 +256,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.lumi-state-btn-content {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "stack";
|
||||
align-items: center;
|
||||
@ -265,10 +270,21 @@ button:disabled {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--lumi-space-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lumi-state-btn [data-state-view][hidden] {
|
||||
display: none !important;
|
||||
.lumi-state-btn [data-state-view][data-state-hidden="true"] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lumi-state-btn [data-state-view][data-state-hidden="false"] {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.button.full,
|
||||
.lumi-state-btn.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lumi-state-btn-spinner {
|
||||
@ -317,6 +333,18 @@ button:disabled {
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.input-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.input-action-row input[type="file"],
|
||||
.input-action-row input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > label:first-child,
|
||||
fieldset > legend {
|
||||
color: var(--lumi-text);
|
||||
@ -610,6 +638,123 @@ input[type="color"] {
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.homepage-json-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.homepage-builder {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.homepage-builder-list {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.homepage-builder-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.homepage-builder-row-header,
|
||||
.homepage-builder-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.homepage-builder-field {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.homepage-builder-field:has(input[type="checkbox"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.homepage-builder-field span {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-metric-grid,
|
||||
.dashboard-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.dashboard-metric-grid {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.dashboard-metric-grid > div,
|
||||
.dashboard-chart-card {
|
||||
min-width: 0;
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.dashboard-metric-grid span,
|
||||
.dashboard-chart-card figcaption {
|
||||
display: block;
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-metric-grid strong {
|
||||
display: block;
|
||||
margin-top: var(--lumi-space-1);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.dashboard-chart-grid {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.dashboard-chart-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-card svg {
|
||||
width: 100%;
|
||||
min-height: 9rem;
|
||||
margin-top: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.log-controls label {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.log-controls label:first-child {
|
||||
min-width: min(18rem, 100%);
|
||||
}
|
||||
|
||||
.log-controls label > span {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
padding: var(--lumi-space-4);
|
||||
background: rgba(5, 10, 12, 0.62);
|
||||
@ -824,6 +969,14 @@ details > summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-action-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.homepage-builder-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list li {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
getViews(button).forEach((view) => {
|
||||
const isVisible = view.dataset.stateView === nextState;
|
||||
view.hidden = !isVisible;
|
||||
view.dataset.stateHidden = isVisible ? "false" : "true";
|
||||
view.setAttribute("aria-hidden", isVisible ? "false" : "true");
|
||||
});
|
||||
|
||||
|
||||
@ -297,6 +297,10 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.theme-mobile-preview-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 26rem;
|
||||
display: grid;
|
||||
@ -326,6 +330,11 @@
|
||||
background: var(--lumi-border);
|
||||
}
|
||||
|
||||
.theme-preview-nav > span.is-active {
|
||||
width: 2.35rem;
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
.theme-preview-logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
@ -344,28 +353,21 @@
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-preview-heading {
|
||||
width: 60%;
|
||||
height: 1.2rem;
|
||||
.theme-preview-content h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: calc(1.25rem * var(--lumi-heading-scale));
|
||||
}
|
||||
|
||||
.theme-preview-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-preview-pill {
|
||||
width: max-content;
|
||||
padding: calc(var(--lumi-space-1) * 0.85) var(--lumi-space-2);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-primary) 30%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-text);
|
||||
}
|
||||
|
||||
.theme-preview-lines {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-lines span {
|
||||
width: 82%;
|
||||
height: 0.5rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-text-muted);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.theme-preview-lines span:last-child {
|
||||
width: 58%;
|
||||
background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.theme-preview-sample-card {
|
||||
@ -385,6 +387,13 @@
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card .switch,
|
||||
.theme-preview-badges,
|
||||
.theme-preview-modal-sample,
|
||||
.theme-spacing-sample {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -394,6 +403,73 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-preview-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-badges .badge,
|
||||
.theme-preview-badges .pill {
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.theme-preview-dirty {
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-preview-table {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-table .table {
|
||||
min-width: 16rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.theme-preview-table th,
|
||||
.theme-preview-table td {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.theme-preview-modal-sample {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-spacing-sample {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span {
|
||||
width: var(--lumi-space-4);
|
||||
height: var(--lumi-space-4);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span:nth-child(2) {
|
||||
width: var(--lumi-space-5);
|
||||
height: var(--lumi-space-5);
|
||||
background: var(--lumi-accent);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span:nth-child(3) {
|
||||
width: var(--lumi-space-6);
|
||||
height: var(--lumi-space-6);
|
||||
background: var(--lumi-info);
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
border-color: var(--lumi-primary) !important;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important;
|
||||
@ -439,18 +515,11 @@
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 16rem;
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr);
|
||||
.theme-mobile-preview-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.theme-preview-nav,
|
||||
.theme-preview-content,
|
||||
.theme-preview-sample-card {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-popout-button {
|
||||
.theme-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
const form = editor?.querySelector("[data-theme-form]");
|
||||
if (!editor || !form) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const originalScheme = root.dataset.colorScheme || "";
|
||||
const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]"));
|
||||
const tokenVariables = {
|
||||
bg1: "--bg-1",
|
||||
bg2: "--bg-2",
|
||||
@ -61,31 +60,45 @@
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreview = () => {
|
||||
root.dataset.colorScheme = previewMode;
|
||||
const buildPreviewVariables = () => {
|
||||
const variables = [];
|
||||
form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => {
|
||||
const variable = tokenVariables[input.dataset.themeToken];
|
||||
if (variable) root.style.setProperty(variable, input.value);
|
||||
if (variable) variables.push([variable, input.value]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-role]").forEach((input) => {
|
||||
root.style.setProperty(`--role-${input.dataset.themeRole}`, input.value);
|
||||
variables.push([`--role-${input.dataset.themeRole}`, input.value]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
|
||||
const config = metricVariables[input.dataset.themeMetric];
|
||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
||||
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-font]").forEach((select) => {
|
||||
const variable = typographyVariables[select.dataset.themeFont];
|
||||
const stack = select.selectedOptions[0]?.dataset.fontStack;
|
||||
if (variable && stack) root.style.setProperty(variable, stack);
|
||||
if (variable && stack) variables.push([variable, stack]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
|
||||
const config = typographyVariables[input.dataset.themeTypography];
|
||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
||||
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
|
||||
const applyVariables = (target, variables) => {
|
||||
if (!target) return;
|
||||
target.dataset.colorScheme = previewMode;
|
||||
variables.forEach(([name, value]) => {
|
||||
target.style.setProperty(name, value);
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreview = () => {
|
||||
const variables = buildPreviewVariables();
|
||||
previewRoots().forEach((target) => applyVariables(target, variables));
|
||||
updateOutputs();
|
||||
updateWarnings();
|
||||
syncPopout();
|
||||
syncPopout(variables);
|
||||
};
|
||||
|
||||
const parseHex = (value) => {
|
||||
@ -133,28 +146,87 @@
|
||||
);
|
||||
};
|
||||
|
||||
const getPreviewMarkup = () => editor.querySelector(".theme-preview-window")?.outerHTML || "";
|
||||
|
||||
const currentPreviewVariables = () => {
|
||||
const variables = [];
|
||||
Object.values(tokenVariables).forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
["--role-public", "--role-mod", "--role-admin"].forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
Object.values(metricVariables).forEach(([name]) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
Object.values(typographyVariables).forEach((config) => {
|
||||
const name = Array.isArray(config) ? config[0] : config;
|
||||
variables.push([name, root.style.getPropertyValue(name)]);
|
||||
});
|
||||
return variables.filter(([, value]) => value);
|
||||
};
|
||||
|
||||
const syncPopout = () => {
|
||||
const syncPopout = (variables = buildPreviewVariables()) => {
|
||||
if (!popout || popout.closed) return;
|
||||
popout.document.documentElement.dataset.colorScheme = previewMode;
|
||||
currentPreviewVariables().forEach(([name, value]) => {
|
||||
variables.forEach(([name, value]) => {
|
||||
popout.document.documentElement.style.setProperty(name, value);
|
||||
});
|
||||
};
|
||||
|
||||
const previewShell = () => `<!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("change", applyPreview);
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||
@ -188,28 +260,11 @@
|
||||
return;
|
||||
}
|
||||
popout.document.open();
|
||||
popout.document.write(`<!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="/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.write(previewShell());
|
||||
popout.document.close();
|
||||
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
|
||||
syncPopout();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (originalScheme) root.dataset.colorScheme = originalScheme;
|
||||
else delete root.dataset.colorScheme;
|
||||
});
|
||||
|
||||
applyPreview();
|
||||
})();
|
||||
|
||||
@ -3675,6 +3675,45 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => {
|
||||
const plugins = getPlugins();
|
||||
const logs = listLogs({ limit: 500 });
|
||||
const memory = process.memoryUsage();
|
||||
const count = (table) => {
|
||||
try {
|
||||
return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({
|
||||
uptime_seconds: Math.round(process.uptime()),
|
||||
memory: {
|
||||
rss: memory.rss,
|
||||
heap_used: memory.heapUsed,
|
||||
heap_total: memory.heapTotal
|
||||
},
|
||||
plugins: {
|
||||
total: plugins.length,
|
||||
enabled: plugins.filter((plugin) => plugin.enabled).length
|
||||
},
|
||||
counts: {
|
||||
users: count("user_profiles"),
|
||||
commands: count("custom_commands"),
|
||||
pages: count("custom_pages"),
|
||||
logs: count("logs")
|
||||
},
|
||||
logs: {
|
||||
error: logs.filter((entry) => entry.level === "error").length,
|
||||
warn: logs.filter((entry) => entry.level === "warn").length,
|
||||
info: logs.filter((entry) => entry.level === "info").length,
|
||||
debug: logs.filter((entry) => entry.level === "debug").length
|
||||
},
|
||||
sampled_at: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/settings", requireRole("admin"), (req, res) => {
|
||||
res.render("admin-settings", {
|
||||
title: "Settings",
|
||||
|
||||
@ -43,6 +43,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<h2>Maintenance</h2>
|
||||
<div class="button-group">
|
||||
@ -57,6 +84,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/dashboard.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
|
||||
|
||||
@ -7,13 +7,18 @@
|
||||
<p class="command-subtitle">Core system logs with severity, timestamps, and details.</p>
|
||||
</div>
|
||||
<div class="log-controls">
|
||||
<input
|
||||
class="table-search"
|
||||
type="search"
|
||||
placeholder="Search logs"
|
||||
aria-label="Search logs"
|
||||
data-log-search
|
||||
/>
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input
|
||||
class="table-search"
|
||||
type="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">
|
||||
<option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</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="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Range</span>
|
||||
<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="<%= 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="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Entries</span>
|
||||
<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="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,10 +47,10 @@
|
||||
<section class="card">
|
||||
<h2>Install plugin from ZIP</h2>
|
||||
<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 />
|
||||
<button type="submit" class="button">Upload plugin</button>
|
||||
</div>
|
||||
<button type="submit" class="button">Upload plugin</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
|
||||
@ -115,17 +115,33 @@
|
||||
|
||||
<div class="field full">
|
||||
<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 class="field full">
|
||||
<label>Homepage link buttons JSON</label>
|
||||
<textarea name="homepage_link_buttons" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea>
|
||||
<p class="hint">Fields: enabled, label, description, url, icon_url, permission public/user/mod/admin, sort_order.</p>
|
||||
<div class="field full homepage-builder" data-homepage-builder="links">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<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 class="field full">
|
||||
<label>Homepage hero entries JSON</label>
|
||||
<textarea name="homepage_hero_entries" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea>
|
||||
<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>
|
||||
<div class="field full homepage-builder" data-homepage-builder="heroes">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
@ -147,16 +163,19 @@
|
||||
<div class="nav-icon-actions">
|
||||
<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="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||
<button type="submit" class="button subtle">Upload</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<button type="submit" class="button subtle">Reset</button>
|
||||
</form>
|
||||
<div class="input-action-row">
|
||||
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||
<button type="submit" class="button subtle">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
<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">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<button type="submit" class="button subtle">Reset</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
<script src="/homepage-builder.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -132,6 +132,7 @@
|
||||
action="/admin/theming/custom/<%= customId %>/delete"
|
||||
data-confirm-title="Delete custom theme"
|
||||
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
|
||||
data-confirm-label="Delete theme"
|
||||
>
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
@ -155,6 +156,7 @@
|
||||
<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" 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>
|
||||
|
||||
@ -313,27 +315,56 @@
|
||||
<span class="eyebrow">Live preview</span>
|
||||
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
|
||||
</div>
|
||||
<div class="theme-preview-window">
|
||||
<div class="theme-preview-window" data-theme-preview-root>
|
||||
<div class="theme-preview-nav">
|
||||
<span class="theme-preview-logo">L</span>
|
||||
<span></span><span></span><span></span>
|
||||
<span class="is-active"></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="theme-preview-content">
|
||||
<div class="theme-preview-heading"></div>
|
||||
<div class="theme-preview-lines"><span></span><span></span></div>
|
||||
<span class="eyebrow theme-preview-pill">Preview pill</span>
|
||||
<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">
|
||||
<strong>Community overview</strong>
|
||||
<p>Preview text, surfaces, borders, and controls before saving.</p>
|
||||
<div class="button-group">
|
||||
<span class="button">Primary</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>
|
||||
<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">
|
||||
<span class="status-success">Success</span>
|
||||
<span class="status-warning">Warning</span>
|
||||
<span class="status-danger">Danger</span>
|
||||
</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>
|
||||
|
||||
@ -24,9 +24,10 @@
|
||||
<section class="card">
|
||||
<h2>Upload bot update</h2>
|
||||
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full">
|
||||
<input type="file" name="update_zip" accept=".zip" required />
|
||||
</div>
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="update_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload bot update</button>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Patch mode (apply only files in ZIP, skip full package verification)</label>
|
||||
<label class="switch">
|
||||
@ -35,21 +36,16 @@
|
||||
<span class="switch-text">Patch mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Upload bot update</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Upload plugin update</h2>
|
||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Upload plugin update</button>
|
||||
</div>
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload plugin update</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
>
|
||||
<span class="lumi-state-btn-content">
|
||||
<% 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><% } %>
|
||||
<span><%= state.text %></span>
|
||||
</span>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user