ui: add streamed interactions and homepage controls

This commit is contained in:
Franz Rolfsvaag 2026-06-16 02:49:15 +02:00
parent 0512fa5931
commit 0d4431924a
21 changed files with 979 additions and 43 deletions

View File

@ -16,6 +16,11 @@ from visual tokens and reusable components.
- `src/web/public/lumi-state-button.js` and
`src/web/views/partials/state-button.ejs`: reusable multi-state button
behavior for submit/loading/success actions.
- `src/web/public/lumi-interactions.js`: progressive interaction layer for
server-sent events, no-auto-refresh notices, dirty settings save bars,
expandable settings containers, refresh prompts, and soft navigation.
- `src/services/web-events.js`: small role-aware Server-Sent Events bus exposed
at `GET /api/events` for authenticated users.
- `src/web/public/styles.css`: legacy and feature-specific styles that still use
the shared tokens. New general-purpose styling belongs in the Lumi UI files.
- `src/web/views/partials/page-header.ejs`: standard page title and description.
@ -29,6 +34,35 @@ Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
and `status-indicator` before adding one-off layout rules. Preserve existing IDs,
field names, data attributes, and JavaScript hooks when restyling a page.
## Interaction Rules
Pages should not self-refresh for state or progress changes. Core connection
recovery now displays a notice instead of calling `window.location.reload()`.
Server-originated events use `GET /api/events` with explicit event names such as
`server:status`, `server:warning`, `ai:model_status`, and
`data:new_available`. Admin-only events must be published with `{ role: "admin" }`.
List/data updates should announce that new data exists and show a refresh prompt.
The shared refresh prompt uses a 3-second cooldown before another refresh can be
requested. It does not replace list contents automatically.
Forms that represent page settings should add `data-lumi-settings-form`.
Action-only forms must not use that attribute. The shared dirty-state layer
tracks original values, marks changed fields with theme-aware unsaved styling,
shows a top Save changes bar, warns before accidental navigation, and clears
markers only after successful saves.
Expandable settings rows use `data-lumi-expandable-settings` on a `<details>`
container. Preview text can be wired with `data-placeholder-preview="#field-id"`;
known placeholders such as `{gifter_username}`, `{item_name}`,
`{creator_username}`, and `{amount_display}` render with plausible sample values
without changing the saved template.
Soft navigation progressively enhances same-origin links by replacing
`main.content`, updating history, and fading content in place. If a fetch fails,
JavaScript is unavailable, or unsaved settings are present, navigation falls back
to normal browser behavior.
## Themes
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
@ -74,6 +108,37 @@ Admins can change the localhost username and password from **Admin > Settings**
when the settings page itself is accessed through localhost. Leaving the
password field blank keeps the existing password.
## Lumi AI Settings And Feedback
Lumi AI's main Selected model dropdown lists only installed/downloaded models.
If the configured model is missing, the settings page shows a warning and saving
requires selecting an installed model. Main context size is a preset dropdown:
Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported
freeform context values are rejected server-side.
AI feedback supports `feedback_kind` values `strict_correction` and
`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where
the model called the wrong tool or failed to call an expected tool. Review,
edit, and implementation views show both the kind and tag so admins can tell
direct answer corrections from broader tool-calling or instruction guidance.
## Homepage Content
Admins can define homepage external link buttons in `homepage_link_buttons` from
Admin > Settings. Each entry may include `enabled`, `label`, `description`,
`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and
`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are
filtered server-side by permission.
Admins can define priority-based hero entries in `homepage_hero_entries`.
Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`,
`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and
`none`. The homepage renders the first enabled, available entry the current user
can access. Hero entries support priority/order, permission, source/embed/image
URLs, video IDs, availability mode, autoplay mode metadata, and duration fields.
Slow external availability checks are intentionally avoided; entries fail
closed if required local configuration is missing.
## Visual references
- [Home, desktop](screenshots/lumi-home-desktop.png)

View File

@ -36,6 +36,8 @@ class CorrectionStore {
const entry = {
id: crypto.randomUUID(),
source_feedback_id: feedback.id,
feedback_kind: feedback.feedback_kind || "strict_correction",
feedback_tag: feedback.feedback_tag || "",
prompt: feedback.user_message,
corrected_answer: answer,
rejected_answer: feedback.assistant_answer,
@ -166,6 +168,9 @@ class CorrectionStore {
.filter((entry) => ["correction", "route_alias", "predefined_answer"].includes(entry.target))
.map((entry) => [
`Reviewed correction for a similar request (minimum role: ${entry.min_role}):`,
entry.feedback_kind === "instruction_based" || entry.feedback_tag === "wrong_tool_usage"
? `Revision guidance: ${entry.feedback_tag === "wrong_tool_usage" ? "tool-calling behavior" : "instruction"}`
: "",
`Request: ${entry.prompt}`,
`Approved answer: ${entry.corrected_answer}`,
entry.expected_link ? `Verified link: ${entry.expected_link}` : ""

View File

@ -12,9 +12,12 @@ const FEEDBACK_TAGS = Object.freeze([
"unsafe",
"should_clarify",
"bad_code",
"wrong_scope"
"wrong_scope",
"wrong_tool_usage"
]);
const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]);
class FeedbackStore {
constructor(options = {}) {
this.file = options.file || resolveData("feedback", "reviews.json");
@ -34,6 +37,9 @@ class FeedbackStore {
model: clean(input.model, 200),
timestamp: validDate(input.timestamp) || new Date().toISOString(),
feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind
: "strict_correction",
optional_correction: clean(input.optional_correction, 16000),
status: "pending",
submitted_by: String(actor?.id || "anonymous"),
@ -70,6 +76,7 @@ class FeedbackStore {
return this.mutate(id, (entry) => ({
...entry,
feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag,
feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction",
optional_correction: clean(values.optional_correction, 16000),
review_notes: clean(values.review_notes, 4000),
reviewed_by: String(actor.id),
@ -196,6 +203,7 @@ function validDate(value) {
}
module.exports = {
FEEDBACK_KINDS,
FEEDBACK_TAGS,
FeedbackStore,
improvementAccess,

View File

@ -25,7 +25,7 @@ const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits");
const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands");
const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics");
const { formatAssistantResponse } = require("./backend/response_formatter");
const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
const { EvalStore } = require("./backend/evals");
const { TrainingExporter } = require("./backend/training_export");
@ -39,6 +39,12 @@ const storage = require("./backend/storage");
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
const PLUGIN_ID = "lumi_ai";
const CONTEXT_OPTIONS = Object.freeze([
{ label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." },
{ label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." },
{ label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." },
{ label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." }
]);
const modelManifest = require("./models_manifest.json");
const runtimeManifest = require("./runtime_manifest.json");
@ -155,6 +161,10 @@ module.exports = {
if (config.enabled) {
ensureGateRuntime().catch((error) => {
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_start_failed", message: error.message });
web.emitEvent?.("ai:model_status", {
status: "gate_start_failed",
message: `Lumi AI gate runtime failed to start: ${error.message}`
}, { role: "admin" });
});
}
const main = await ensureMainRuntime(options);
@ -171,6 +181,10 @@ module.exports = {
try { await ensureGateRuntime(); }
catch (error) {
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_restart_failed", message: error.message });
web.emitEvent?.("ai:model_status", {
status: "gate_restart_failed",
message: `Lumi AI gate runtime restart failed: ${error.message}`
}, { role: "admin" });
}
}
return { ...main, gate: await gateRuntime.health() };
@ -184,7 +198,12 @@ module.exports = {
) return;
gateRecoveryPending = true;
try { await ensureGateRuntime(); }
catch {}
catch (error) {
web.emitEvent?.("ai:model_status", {
status: "gate_recovery_failed",
message: `Lumi AI gate recovery failed: ${error.message}`
}, { role: "admin" });
}
finally { gateRecoveryPending = false; }
}, 30000);
gateMonitor.unref?.();
@ -287,17 +306,22 @@ module.exports = {
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
].filter((check) => !check.valid);
for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`);
const models = modelManifest.models.map((model) => ({
...model,
downloaded: fs.existsSync(resolveData("models", model.filename)),
installed_size: fs.existsSync(resolveData("models", model.filename))
? fs.statSync(resolveData("models", model.filename)).size
: 0,
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
}));
const installedModels = models.filter((model) => model.downloaded);
res.render(path.join(__dirname, "views", "settings.ejs"), {
title: "Lumi AI",
config,
models: modelManifest.models.map((model) => ({
...model,
downloaded: fs.existsSync(resolveData("models", model.filename)),
installed_size: fs.existsSync(resolveData("models", model.filename))
? fs.statSync(resolveData("models", model.filename)).size
: 0,
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
})),
models,
installedModels,
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
contextOptions: CONTEXT_OPTIONS,
runtimeTarget,
runtimeManifest,
runtimeStatus,
@ -338,7 +362,15 @@ module.exports = {
if (!req.session.user?.isAdmin) return denied(res);
const model = getModel(req.body.selected_model_id);
if (!model) return flash(req, res, "error", "Unknown model.");
const contextSize = boundedInt(req.body.context_size, 512, 131072, 4096);
if (!fs.existsSync(resolveData("models", model.filename))) {
return flash(req, res, "error", "Selected model must be installed before it can be saved.");
}
const contextValues = CONTEXT_OPTIONS.map((option) => option.value);
const requestedContext = Number(req.body.context_size);
if (!contextValues.includes(requestedContext)) {
return flash(req, res, "error", "Choose a supported AI context size.");
}
const contextSize = requestedContext;
const previousConfig = config;
config = saveConfig({
...config,
@ -891,6 +923,7 @@ module.exports = {
model: req.body.model,
timestamp: req.body.timestamp,
feedback_tag: req.body.feedback_tag,
feedback_kind: req.body.feedback_kind,
optional_correction: req.body.optional_correction
}, req.session.user);
return res.status(201).json({ success: true, id: entry.id });
@ -1148,6 +1181,7 @@ module.exports = {
config,
access,
feedbackTags: FEEDBACK_TAGS,
feedbackKinds: FEEDBACK_KINDS,
promotionTargets: PROMOTION_TARGETS,
reviews: feedbackStore.list({
page: req.query.review_page,

View File

@ -8,7 +8,7 @@
.lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); }
.lumi-ai-state.warming { background: #d29922; box-shadow: 0 0 0 3px color-mix(in srgb, #d29922 18%, transparent); }
.lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); }
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 16px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; bottom: 14px; height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 96px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
.lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; }
.lumi-ai-resize-handle { position: relative; cursor: ns-resize; background: var(--surface-2); touch-action: none; }
.lumi-ai-resize-handle::after { content: ""; position: absolute; top: 3px; left: 50%; width: 42px; height: 2px; transform: translateX(-50%); border-radius: 2px; background: var(--border); }

View File

@ -65,15 +65,8 @@
};
const positionPanel = (height = panelHeight()) => {
const viewportHeight = window.innerHeight;
const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect();
const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0
? Math.max(MIN_HEIGHT + 8, footerRect.top - 8)
: viewportHeight - 8;
const maximum = Math.max(MIN_HEIGHT, bottomLimit - 8);
const maximum = Math.max(MIN_HEIGHT, window.innerHeight - 96);
const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height));
const top = Math.max(8, bottomLimit - clampedHeight);
panel.style.setProperty("--lumi-ai-top", `${top}px`);
panel.style.height = `${clampedHeight}px`;
return clampedHeight;
};
@ -296,21 +289,33 @@
"unsafe",
"should_clarify",
"bad_code",
"wrong_scope"
"wrong_scope",
"wrong_tool_usage"
]) {
const option = document.createElement("option");
option.value = tag;
option.textContent = tag.replaceAll("_", " ");
select.append(option);
}
const kind = document.createElement("select");
kind.setAttribute("aria-label", "Feedback type");
for (const [value, label] of [
["strict_correction", "Strict correction"],
["instruction_based", "Instruction-based guidance"]
]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
kind.append(option);
}
const correction = document.createElement("input");
correction.maxLength = 16000;
correction.placeholder = "Optional correction";
correction.setAttribute("aria-label", "Optional correction");
correction.placeholder = "Correction or instruction for future replies";
correction.setAttribute("aria-label", "Correction or instruction");
const submitFeedback = document.createElement("button");
submitFeedback.type = "submit";
submitFeedback.textContent = "Send feedback";
controls.append(select, correction, submitFeedback);
controls.append(select, kind, correction, submitFeedback);
controls.addEventListener("submit", async (event) => {
event.preventDefault();
submitFeedback.disabled = true;
@ -321,6 +326,7 @@
body: JSON.stringify({
...context,
feedback_tag: select.value,
feedback_kind: kind.value,
optional_correction: correction.value.trim()
})
});

View File

@ -162,6 +162,7 @@
workload.addEventListener("change", refreshCapacity);
model.addEventListener("change", refreshCapacity);
context.addEventListener("input", scheduleCapacity);
context.addEventListener("change", refreshCapacity);
refreshCapacity();
}
if (accessForm) {

View File

@ -43,14 +43,14 @@
<% reviews.entries.forEach((review) => { %>
<article class="improvement-card">
<header>
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.status %></span></div>
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
</header>
<div class="improvement-pair">
<div><span>User message</span><pre><%= review.user_message %></pre></div>
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
</div>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong>Suggested correction</strong><pre><%= review.optional_correction %></pre></div><% } %>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
<div class="improvement-actions">
<% if (access.can_flag) { %>
@ -93,7 +93,8 @@
<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 full"><label>Optional correction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></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 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>
</form>
@ -105,7 +106,7 @@
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/implement" class="form-grid ai-form">
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
<div class="field full"><label>Corrected / expected answer</label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
<div class="field"><label>Route alias</label><input name="route_alias" /></div>

View File

@ -202,7 +202,7 @@
</form>
</section>
<form method="post" action="/plugins/lumi_ai/settings">
<form method="post" action="/plugins/lumi_ai/settings" data-lumi-settings-form>
<section class="ai-band" id="assistant">
<div class="ai-section-heading">
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
@ -222,9 +222,24 @@
</div>
<div class="field">
<label for="selected-model">Selected model</label>
<select id="selected-model" name="selected_model_id" data-gpu-model><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
<% if (!selectedModelInstalled) { %>
<div class="callout danger">The currently selected model is not installed. Choose an installed model before saving.</div>
<% } %>
<select id="selected-model" name="selected_model_id" data-gpu-model required>
<% if (!installedModels.length) { %>
<option value="">No installed models available</option>
<% } %>
<% installedModels.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %>
</select>
</div>
<div class="field">
<label>Context size</label>
<select name="context_size" data-gpu-context>
<% contextOptions.forEach((option) => { %>
<option value="<%= option.value %>" <%= Number(config.context_size) === option.value ? "selected" : "" %>><%= option.label %> - <%= option.description %></option>
<% }) %>
</select>
</div>
<div class="field"><label>Context size</label><input type="number" name="context_size" min="512" max="131072" value="<%= config.context_size %>" data-gpu-context /></div>
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
<div class="field full ai-gpu-control" data-gpu-control data-endpoint="/plugins/lumi_ai/api/gpu-capacity">
<div class="ai-gpu-label">

View File

@ -21,7 +21,7 @@
.diagnostic-grid > div,
.destination-panel,
.template-panel {
.template-panel:not(.lumi-expandable-settings) {
border: 1px solid var(--border);
background: var(--surface-2);
padding: 14px;
@ -99,7 +99,7 @@
padding-top: 12px;
}
.template-panel {
.template-panel:not(.lumi-expandable-settings) {
display: grid;
gap: 10px;
}

View File

@ -164,10 +164,22 @@
<p>No active platform templates are available.</p>
<% } %>
<% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %>
<form method="post" action="/plugins/throne_wishlist/templates" class="template-panel">
<details class="template-panel lumi-expandable-settings" data-lumi-expandable-settings>
<summary>
<span>
<strong><%= status?.label || platform %></strong>
<span class="lumi-preview-line" data-placeholder-preview="#template-<%= eventType %>-<%= platform %>" data-fallback="<%= template?.template || "" %>"></span>
</span>
<label class="switch" onclick="event.stopPropagation()">
<input type="checkbox" class="switch-input" disabled <%= template?.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
</label>
</summary>
<form method="post" action="/plugins/throne_wishlist/templates" class="lumi-expandable-body form-grid" data-lumi-settings-form>
<input type="hidden" name="event_type" value="<%= eventType %>" />
<input type="hidden" name="platform" value="<%= platform %>" />
<div class="template-heading">
<div class="template-heading field full">
<strong><%= status?.label || platform %></strong>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= template?.enabled ? "checked" : "" %> />
@ -175,10 +187,13 @@
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
</label>
</div>
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
<div class="field full">
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
</div>
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
</form>
</details>
<% }) %>
</div>
</section>

View File

@ -101,6 +101,37 @@ function verifyThemeService() {
assert(rendered.includes("data-lumi-state-button"));
assert(rendered.includes("Built-in &middot; read-only"));
const homeView = path.join(root, "src", "web", "views", "home.ejs");
const homeRendered = ejs.render(fs.readFileSync(homeView, "utf8"), {
title: "Home",
siteTitle: "Lumi Bot",
assetVersion: "verify",
theme: renamed,
botAvatar: null,
navSections: [],
user: { username: "Admin" },
userAvatar: null,
userInitial: "A",
platformLogins: [],
flash: null,
softError: null,
homepageLinks: [{
label: "Twitch",
description: "Watch live",
url: "https://example.com",
fallback_icon: "T"
}],
homepageHero: {
type: "static_image",
title: "Featured",
description: "Featured content",
image_url: "https://example.com/hero.png",
source_url: "https://example.com"
}
}, { filename: homeView });
assert(homeRendered.includes("homepage-link-button"));
assert(homeRendered.includes("homepage-dynamic-hero"));
const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs");
const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), {
title: "Localhost Login",

View File

@ -0,0 +1,63 @@
const clients = new Map();
function subscribe(req, res) {
const id = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
const user = req.session?.user || null;
const client = { id, res, user, connectedAt: Date.now() };
clients.set(id, client);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no"
});
send(client, "server:status", {
status: "connected",
message: "Lumi event stream connected.",
connected_at: client.connectedAt
});
const keepAlive = setInterval(() => {
send(client, "server:status", { status: "heartbeat", at: Date.now() });
}, 25000);
req.on("close", () => {
clearInterval(keepAlive);
clients.delete(id);
});
}
function publish(event, payload = {}, options = {}) {
let delivered = 0;
for (const client of clients.values()) {
if (!canReceive(client.user, options)) continue;
send(client, event, payload);
delivered += 1;
}
return delivered;
}
function send(client, event, payload) {
try {
client.res.write(`event: ${event}\n`);
client.res.write(`data: ${JSON.stringify({ ...payload, event, at: Date.now() })}\n\n`);
} catch {
clients.delete(client.id);
}
}
function canReceive(user, options = {}) {
const role = options.role || "public";
if (role === "public") return true;
if (!user) return false;
if (role === "user") return true;
if (role === "mod") return Boolean(user.isMod || user.isAdmin);
if (role === "admin") return Boolean(user.isAdmin);
return false;
}
module.exports = {
publishWebEvent: publish,
subscribeWebEvents: subscribe
};

View File

@ -489,8 +489,9 @@
const response = await fetch(healthEndpoint, { cache: "no-store" });
if (response.ok) {
if (connectionLost) {
window.location.reload();
return;
window.LumiInteractions?.showEventNotice?.({
message: "Connection restored. Refresh manually if you need newer page data."
}, "info");
}
connectionLost = false;
} else {

View File

@ -90,6 +90,65 @@ pre {
z-index: 1;
}
.homepage-dynamic-hero {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(18rem, 1.1fr);
align-items: center;
gap: var(--lumi-space-5);
}
.homepage-hero-media {
width: 100%;
min-height: 18rem;
aspect-ratio: 16 / 9;
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface-subtle);
object-fit: cover;
}
.homepage-link-strip {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-3);
}
.homepage-link-button {
flex: 1 1 14rem;
min-height: var(--lumi-control-height);
display: flex;
align-items: center;
gap: var(--lumi-space-3);
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface);
color: var(--lumi-text);
box-shadow: var(--lumi-shadow-sm);
text-decoration: none;
}
.homepage-link-button:hover {
border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border));
background: var(--lumi-surface-raised);
}
.homepage-link-button img,
.homepage-link-button > span:first-child {
width: 2rem;
height: 2rem;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: var(--lumi-radius-sm);
background: var(--lumi-surface-subtle);
}
.homepage-link-button small {
display: block;
color: var(--lumi-text-muted);
}
.card,
.panel,
.lumi-panel {
@ -425,6 +484,126 @@ input[type="color"] {
background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface));
}
.is-unsaved {
border-color: var(--lumi-color-unsaved-border) !important;
background: var(--lumi-color-unsaved-bg) !important;
color: var(--lumi-color-unsaved-text);
box-shadow: 0 0 0 2px var(--lumi-color-unsaved-ring);
}
.lumi-savebar {
position: fixed;
top: var(--lumi-space-3);
left: 50%;
z-index: 80;
width: min(42rem, calc(100vw - 2rem));
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-3);
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-savebar-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-savebar-bg);
box-shadow: var(--lumi-savebar-shadow);
backdrop-filter: blur(18px);
transform: translate(-50%, 0);
transition: opacity var(--lumi-transition), transform var(--lumi-transition);
}
.lumi-savebar[hidden] {
display: none;
}
.lumi-savebar.is-hidden-by-scroll {
opacity: 0;
pointer-events: none;
transform: translate(-50%, -110%);
}
.lumi-savebar.has-error {
border-color: var(--lumi-danger);
}
.lumi-event-notices {
position: fixed;
right: var(--lumi-space-4);
bottom: var(--lumi-space-4);
z-index: 90;
display: grid;
gap: var(--lumi-space-2);
width: min(24rem, calc(100vw - 2rem));
}
.lumi-event-notice,
.lumi-refresh-prompt {
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface);
box-shadow: var(--lumi-shadow-md);
}
.lumi-event-notice.warning {
border-color: color-mix(in srgb, var(--lumi-warning) 50%, var(--lumi-border));
}
.lumi-event-notice.danger {
border-color: color-mix(in srgb, var(--lumi-danger) 55%, var(--lumi-border));
}
.lumi-refresh-prompt {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-3);
}
.lumi-expandable-settings {
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface);
box-shadow: var(--lumi-shadow-sm);
overflow: clip;
}
.lumi-expandable-settings + .lumi-expandable-settings {
margin-top: var(--lumi-space-3);
}
.lumi-expandable-settings summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--lumi-space-3);
align-items: center;
padding: var(--lumi-space-3);
cursor: pointer;
}
.lumi-expandable-settings[open] summary {
border-bottom: 1px solid var(--lumi-border);
}
.lumi-expandable-body {
padding: var(--lumi-space-4);
}
.lumi-preview-line {
color: var(--lumi-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content.is-soft-loading {
opacity: 0.55;
transition: opacity 90ms ease;
}
.content.is-soft-loaded {
animation: lumi-soft-in 140ms ease;
}
.list li {
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
@ -550,6 +729,17 @@ details > summary {
}
}
@keyframes lumi-soft-in {
from {
opacity: 0.45;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 700px) {
h1 {
font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale));
@ -586,6 +776,14 @@ details > summary {
top: -5rem;
}
.homepage-dynamic-hero {
grid-template-columns: 1fr;
}
.homepage-hero-media {
min-height: 12rem;
}
.button,
button.button,
input[type="submit"].button,
@ -602,6 +800,17 @@ details > summary {
padding: 0.55rem 0.8rem;
}
.lumi-savebar,
.lumi-refresh-prompt {
align-items: stretch;
flex-direction: column;
}
.lumi-event-notices {
right: var(--lumi-space-3);
bottom: var(--lumi-space-3);
}
.table-tools,
.table-controls,
.log-controls {

View File

@ -0,0 +1,299 @@
(() => {
const initializedForms = new WeakSet();
const initializedExpandables = new WeakSet();
const REFRESH_COOLDOWN_MS = 3000;
let saveBar = null;
let stream = null;
let lastScrollY = window.scrollY;
const init = (root = document) => {
initSettingsDirty(root);
initExpandables(root);
initSoftNavigation(root);
};
const ensureSaveBar = () => {
if (saveBar) return saveBar;
saveBar = document.createElement("div");
saveBar.className = "lumi-savebar";
saveBar.hidden = true;
saveBar.innerHTML = `
<div>
<strong data-savebar-count>Unsaved changes</strong>
<span class="hint" data-savebar-status>Review and save changed settings on this page.</span>
</div>
<button type="button" class="button" data-savebar-submit>Save changes</button>
`;
document.body.append(saveBar);
saveBar.querySelector("[data-savebar-submit]").addEventListener("click", saveDirtyForms);
window.addEventListener("scroll", updateSaveBarScroll, { passive: true });
return saveBar;
};
const formFields = (form) => Array.from(form.elements).filter((field) =>
field.name && !field.disabled && !["submit", "button", "reset", "file"].includes(field.type)
);
const fieldValue = (field) => {
if (field.type === "checkbox") return field.checked ? "on" : "";
if (field.type === "radio") return field.checked ? field.value : "";
return field.value;
};
const snapshotForm = (form) => {
const snapshot = new Map();
for (const field of formFields(form)) {
if (field.type === "radio") {
if (!snapshot.has(field.name)) snapshot.set(field.name, "");
if (field.checked) snapshot.set(field.name, field.value);
} else {
snapshot.set(field.name, fieldValue(field));
}
}
return snapshot;
};
const isFieldDirty = (field, snapshot) => {
const original = snapshot.get(field.name) || "";
if (field.type === "radio") {
return field.checked && field.value !== original;
}
return fieldValue(field) !== original;
};
const updateDirtyState = () => {
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form]"));
let dirtyCount = 0;
for (const form of forms) {
const snapshot = form._lumiSnapshot || snapshotForm(form);
let formDirty = false;
for (const field of formFields(form)) {
const dirty = isFieldDirty(field, snapshot);
const container = field.closest(".field, .theme-color-control, .theme-range-control, .theme-select-control, fieldset");
container?.classList.toggle("is-unsaved", dirty);
formDirty = formDirty || dirty;
if (dirty) dirtyCount += 1;
}
form.classList.toggle("has-unsaved-settings", formDirty);
}
const bar = ensureSaveBar();
bar.hidden = dirtyCount === 0;
bar.classList.toggle("is-visible", dirtyCount > 0);
bar.querySelector("[data-savebar-count]").textContent =
dirtyCount === 1 ? "1 unsaved setting" : `${dirtyCount} unsaved settings`;
if (dirtyCount === 0) bar.querySelector("[data-savebar-status]").textContent = "Saved.";
updateSaveBarScroll();
};
function initSettingsDirty(root) {
root.querySelectorAll?.("form[data-lumi-settings-form]").forEach((form) => {
if (initializedForms.has(form)) return;
initializedForms.add(form);
form._lumiSnapshot = snapshotForm(form);
form.addEventListener("input", updateDirtyState);
form.addEventListener("change", updateDirtyState);
form.addEventListener("submit", () => {
form._lumiSnapshot = snapshotForm(form);
window.setTimeout(updateDirtyState, 0);
});
});
if (document.querySelector("form[data-lumi-settings-form]")) {
ensureSaveBar();
updateDirtyState();
window.addEventListener("beforeunload", warnDirtyNavigation);
}
}
async function saveDirtyForms() {
const bar = ensureSaveBar();
const button = bar.querySelector("[data-savebar-submit]");
const status = bar.querySelector("[data-savebar-status]");
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
if (!forms.length) return;
button.disabled = true;
status.textContent = "Saving...";
try {
for (const form of forms) {
const response = await fetch(form.action || window.location.href, {
method: form.method || "POST",
body: new FormData(form),
headers: { Accept: "text/html,application/json" },
redirect: "follow"
});
if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`);
form._lumiSnapshot = snapshotForm(form);
}
status.textContent = "Saved.";
updateDirtyState();
} catch (error) {
status.textContent = error.message || "Save failed.";
bar.classList.add("has-error");
} finally {
button.disabled = false;
window.setTimeout(() => bar.classList.remove("has-error"), 2500);
}
}
function warnDirtyNavigation(event) {
if (!document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
event.preventDefault();
event.returnValue = "";
}
function updateSaveBarScroll() {
if (!saveBar || saveBar.hidden) return;
const canScroll = document.documentElement.scrollHeight > window.innerHeight + 12;
const next = window.scrollY;
saveBar.classList.toggle("is-hidden-by-scroll", canScroll && next > lastScrollY + 4);
lastScrollY = next;
}
function initExpandables(root) {
root.querySelectorAll?.("[data-lumi-expandable-settings]").forEach((item) => {
if (initializedExpandables.has(item)) return;
initializedExpandables.add(item);
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
item.addEventListener("input", () => {
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
});
});
}
function updatePlaceholderPreview(target) {
const source = target.closest("[data-lumi-expandable-settings]")?.querySelector(target.dataset.placeholderPreview);
const text = source?.value || target.dataset.fallback || "";
const replacements = {
gifter_username: "SomeUser123",
item_name: "Cool Item",
creator_username: "CreatorName",
amount_display: "$12.34",
username: "SomeUser123",
platform: "Twitch"
};
target.textContent = text.replace(/\{([^{}]+)\}/g, (full, key) => replacements[key] || full);
}
function connectEvents() {
if (!window.EventSource || stream) return;
stream = new EventSource("/api/events");
stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning"));
stream.addEventListener("server:status", (event) => {
const data = readEvent(event);
if (data.status === "connected") document.body.dataset.eventStream = "connected";
});
stream.addEventListener("ai:model_status", (event) => showEventNotice(readEvent(event), "danger"));
stream.addEventListener("data:new_available", (event) => showRefreshPrompt(readEvent(event)));
stream.onerror = () => {
document.body.dataset.eventStream = "disconnected";
};
}
function readEvent(event) {
try { return JSON.parse(event.data || "{}"); } catch { return {}; }
}
function noticeRoot() {
let root = document.querySelector("[data-lumi-event-notices]");
if (!root) {
root = document.createElement("div");
root.className = "lumi-event-notices";
root.dataset.lumiEventNotices = "";
document.body.append(root);
}
return root;
}
function showEventNotice(data, tone = "info") {
const item = document.createElement("div");
item.className = `lumi-event-notice ${tone}`;
item.setAttribute("role", tone === "danger" ? "alert" : "status");
item.textContent = data.message || data.status || "Lumi status changed.";
noticeRoot().append(item);
window.setTimeout(() => item.remove(), 9000);
}
function showRefreshPrompt(data) {
const item = document.createElement("div");
item.className = "lumi-refresh-prompt";
item.setAttribute("role", "status");
const label = document.createElement("span");
label.textContent = data.message || "New data is available.";
const button = document.createElement("button");
button.type = "button";
button.className = "button subtle";
button.textContent = "Refresh";
button.addEventListener("click", () => {
button.disabled = true;
window.setTimeout(() => { button.disabled = false; }, REFRESH_COOLDOWN_MS);
if (data.url) window.location.assign(data.url);
else window.location.reload();
});
item.append(label, button);
noticeRoot().append(item);
}
function initSoftNavigation(root) {
root.querySelectorAll?.("a[href]").forEach((link) => {
if (link.dataset.softNavBound || link.target || link.hasAttribute("download")) return;
const url = new URL(link.href, window.location.href);
if (url.origin !== window.location.origin || url.pathname.startsWith("/auth/")) return;
link.dataset.softNavBound = "true";
link.addEventListener("click", (event) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const main = document.querySelector("main.content");
if (!main || document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
event.preventDefault();
softNavigate(url.href);
});
});
}
async function softNavigate(url, push = true) {
const main = document.querySelector("main.content");
if (!main) return window.location.assign(url);
main.classList.add("is-soft-loading");
try {
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" } });
if (!response.ok) throw new Error("Navigation failed.");
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
const nextMain = doc.querySelector("main.content");
if (!nextMain) throw new Error("Navigation target did not contain page content.");
document.title = doc.title || document.title;
main.replaceChildren(...Array.from(nextMain.childNodes));
main.classList.remove("is-soft-loading");
main.classList.add("is-soft-loaded");
window.setTimeout(() => main.classList.remove("is-soft-loaded"), 180);
updateActiveNavigation(new URL(url, window.location.href).pathname);
if (push) history.pushState({}, "", url);
init(main);
executePageScripts(main);
window.scrollTo({ top: 0, behavior: "auto" });
} catch {
window.location.assign(url);
}
}
function updateActiveNavigation(pathname) {
document.querySelectorAll(".nav-link").forEach((link) => {
const url = new URL(link.href, window.location.href);
link.classList.toggle("active", url.pathname === pathname || (url.pathname !== "/" && pathname.startsWith(`${url.pathname}/`)));
});
}
function executePageScripts(root) {
root.querySelectorAll("script").forEach((script) => {
const next = document.createElement("script");
for (const attr of script.attributes) next.setAttribute(attr.name, attr.value);
next.textContent = script.textContent;
script.replaceWith(next);
});
}
window.addEventListener("popstate", () => softNavigate(window.location.href, false));
window.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt };
document.addEventListener("DOMContentLoaded", () => {
init(document);
connectEvents();
});
})();

View File

@ -28,6 +28,13 @@
--lumi-button-text: #ffffff;
--lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black);
--lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white);
--lumi-color-unsaved-bg: color-mix(in srgb, var(--lumi-warning) 13%, var(--lumi-surface));
--lumi-color-unsaved-border: color-mix(in srgb, var(--lumi-warning) 45%, var(--lumi-border));
--lumi-color-unsaved-text: var(--lumi-text);
--lumi-color-unsaved-ring: color-mix(in srgb, var(--lumi-warning) 26%, transparent);
--lumi-savebar-bg: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
--lumi-savebar-border: var(--lumi-color-unsaved-border);
--lumi-savebar-shadow: var(--lumi-shadow-md);
--lumi-space-scale: 1;
--lumi-space-1: calc(0.25rem * var(--lumi-space-scale));

View File

@ -93,6 +93,10 @@ const {
consumeConfirmation,
normalizeAction
} = require("../services/destructive-confirm");
const {
publishWebEvent,
subscribeWebEvents
} = require("../services/web-events");
function ensureSessionSecret() {
let secret = getSetting("session_secret");
@ -1514,6 +1518,113 @@ function getThemeSettings() {
return getActiveTheme();
}
function parseJsonSetting(key, fallback) {
const value = getSetting(key, fallback);
if (Array.isArray(value)) return value;
if (typeof value === "string" && value.trim()) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
return fallback;
}
function safeExternalUrl(value) {
try {
const url = new URL(String(value || ""));
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
} catch {
return "";
}
}
function permissionAllows(user, permission = "public") {
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
return role === "public" ? true : hasAccess(user, role);
}
function fallbackIconForUrl(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, "");
return host.slice(0, 1).toUpperCase();
} catch {
return "↗";
}
}
function homepageLinksForUser(user) {
return parseJsonSetting("homepage_link_buttons", [])
.filter((item) => item && item.enabled !== false)
.filter((item) => permissionAllows(user, item.permission))
.map((item, index) => {
const url = safeExternalUrl(item.url);
if (!url) return null;
return {
id: String(item.id || `link-${index}`),
label: String(item.label || item.description || "External link").slice(0, 80),
description: String(item.description || item.label || "Open link").slice(0, 160),
url,
icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url),
fallback_icon: fallbackIconForUrl(url),
permission: item.permission || "public",
sort_order: Number(item.sort_order) || index
};
})
.filter(Boolean)
.sort((a, b) => a.sort_order - b.sort_order);
}
function homepageHeroForUser(user) {
const entries = parseJsonSetting("homepage_hero_entries", [])
.filter((item) => item && item.enabled !== false)
.filter((item) => permissionAllows(user, item.permission))
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
for (const item of entries) {
const hero = normalizeHomepageHero(item);
if (hero?.available) return hero;
}
return null;
}
function normalizeHomepageHero(item) {
const type = String(item.type || "none");
if (type === "none") {
return item.fallback_behavior === "message"
? { type, available: true, title: item.title || "No featured content", description: item.description || "" }
: null;
}
const sourceUrl = safeExternalUrl(item.source_url);
const embedUrl = safeExternalUrl(item.embed_url);
const imageUrl = safeExternalUrl(item.image_url);
const title = String(item.title || "Featured content").slice(0, 120);
const description = String(item.description || "").slice(0, 500);
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl };
if (type === "youtube_video") {
const videoId = item.video_id || youtubeVideoId(sourceUrl);
if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl };
}
if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) {
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
return { type, available: true, title, description, embed_url: embedUrl, source_url: sourceUrl };
}
return null;
}
function youtubeVideoId(value) {
try {
const url = new URL(value || "");
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
return url.searchParams.get("v") || "";
} catch {
return "";
}
}
function getDiscordSettings() {
return {
discord_client_id: getSetting("discord_client_id", ""),
@ -1942,10 +2053,12 @@ function createWebServer({ loadPlugins, discordClient }) {
assistantPanels.splice(index, 1);
}
};
}
},
emitEvent: publishWebEvent
};
app.use(requireConfigured);
app.get("/api/events", requireAuth, subscribeWebEvents);
app.post("/api/destructive-confirmations", requireAuth, (req, res) => {
try {
res.json(issueConfirmation(req, req.body.action));
@ -2072,7 +2185,9 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/", (req, res) => {
res.render("home", {
title: "Home"
title: "Home",
homepageLinks: homepageLinksForUser(req.session.user),
homepageHero: homepageHeroForUser(req.session.user)
});
});
@ -3601,6 +3716,22 @@ function createWebServer({ loadPlugins, discordClient }) {
setSetting("localhost_login_password", localhostPassword);
}
}
for (const field of ["homepage_link_buttons", "homepage_hero_entries"]) {
if (req.body[field] === undefined) continue;
const raw = String(req.body[field] || "").trim();
if (!raw) {
setSetting(field, []);
continue;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error("Expected an array.");
setSetting(field, parsed);
} catch (error) {
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);
return res.redirect("/admin/settings");
}
}
const platformStatus = getPlatformStatus();
const nextPlatformValues = new Map();
for (const platform of platformStatus) {

View File

@ -5,7 +5,7 @@
pageTitle: "Settings",
description: "Manage core behavior, updates, and platform integrations."
}) %>
<form method="post" action="/admin/settings" class="form-grid">
<form method="post" action="/admin/settings" class="form-grid" data-lumi-settings-form>
<div class="field">
<label>Site title</label>
<input name="site_title" value="<%= settings.site_title || '' %>" />
@ -113,6 +113,21 @@
</div>
<% } %>
<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>
</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>
<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>
<button type="submit" class="button">Save settings</button>
</form>
</section>

View File

@ -7,6 +7,35 @@
<a class="button subtle" href="/leaderboards">Leaderboards</a>
</div>
</section>
<% if (homepageHero) { %>
<section class="homepage-dynamic-hero card" aria-label="Featured community content">
<div>
<span class="eyebrow"><%= homepageHero.type.replaceAll("_", " ") %></span>
<h2><%= homepageHero.title %></h2>
<% if (homepageHero.description) { %><p><%= homepageHero.description %></p><% } %>
<% if (homepageHero.source_url) { %><a class="button subtle" href="<%= homepageHero.source_url %>" target="_blank" rel="noopener noreferrer">Open featured content</a><% } %>
</div>
<% if (homepageHero.image_url) { %>
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
<% } else if (homepageHero.embed_url) { %>
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
<% } %>
</section>
<% } %>
<% if ((homepageLinks || []).length) { %>
<section class="homepage-link-strip" aria-label="Community links">
<% homepageLinks.forEach((link) => { %>
<a class="homepage-link-button" href="<%= link.url %>" target="_blank" rel="noopener noreferrer">
<% if (link.icon_url) { %>
<img src="<%= link.icon_url %>" alt="" loading="lazy" />
<% } else { %>
<span aria-hidden="true"><%= link.fallback_icon %></span>
<% } %>
<span><strong><%= link.label %></strong><small><%= link.description %></small></span>
</a>
<% }) %>
</section>
<% } %>
<section class="grid">
<div class="card">
<h2>Bot control</h2>

View File

@ -26,6 +26,7 @@
</div>
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
<script src="/lumi-interactions.js?v=<%= assetVersion %>"></script>
<script src="/app.js?v=<%= assetVersion %>"></script>
</body>
</html>