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

Review notes: <%= review.review_notes %>

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

Assistant

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

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

No active platform templates are available.

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

Homepage content

+

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

+
+
+ + +

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

+
+
+ + +

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

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

<%= homepageHero.title %>

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

<%= homepageHero.description %>

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

Bot control

diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index b4e1b71..bfca27e 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -26,6 +26,7 @@
+