From f99dbed310da68ae23613fd3f93f9cb72ce3e4d7 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 17 Jun 2026 12:35:17 +0200 Subject: [PATCH] improve update and homepage hero ux --- TODO.md | 41 +++++ src/web/public/homepage-builder.js | 203 +++++++++++++++++++---- src/web/public/lumi-components.css | 77 +++++++++ src/web/server.js | 145 +++++++++++++++-- src/web/views/admin-dashboard.ejs | 6 +- src/web/views/admin-settings.ejs | 12 +- src/web/views/admin-updates.ejs | 248 +++++++++++++++++++---------- src/web/views/home.ejs | 5 + 8 files changed, 609 insertions(+), 128 deletions(-) diff --git a/TODO.md b/TODO.md index 1c71f7a..056a6ee 100644 --- a/TODO.md +++ b/TODO.md @@ -42,7 +42,48 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K - Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates. - Ensure generated OKF is reproducible and not accidentally overwritten by admin edits. +## Update Page UX Improvements + +- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`. +- Continue simplifying advanced update terminology without hiding recovery-critical detail. + +## Homepage Hero System + +- Improve handling of live-only Twitch heroes and live-state detection. +- Add explicit Discord invite-to-widget guidance or lookup support if Discord server IDs are not known. + +## Homepage Hero UX Improvements + +- Review all homepage builder forms for non-technical usability. +- Add tooltips and inline explanations where appropriate. + +## UI Text and Button Language + +- Review all button labels, field labels, headings, helper text, warnings, empty states, and confirmation dialogs across Lumi. +- Replace technical/internal wording with clearer user-facing wording where possible. +- Keep AI-related wording precise enough for admins to understand model, provider, context, retrieval, embeddings, feedback, corrections, and OKF behavior. +- Review the update pages and simplify labels such as `Check plugin`, `Apply safe target`, and `Disable for recovery`. +- Prefer clear action labels such as `Check for updates`, `Update plugin`, `Use recommended version`, `Disable temporarily`, and `Start recovery mode`. +- Add short helper text below advanced or risky update actions explaining what will happen. +- Replace implementation-focused wording with task-focused wording wherever practical. +- Avoid exposing Git, branch, commit, target, repository, cache, migration, or rollback terms unless the user needs them. +- When technical terms are unavoidable, add short tooltips or inline explanations. +- Standardize common action wording across all pages: save, update, check, review, restore, disable, enable, archive, delete, retry, import, export, cancel, and confirm. +- Ensure destructive or system-changing actions have clear confirmation text. +- Ensure error messages explain both what went wrong and what the admin can try next. +- Review plugin management, core settings, update pages, homepage management, OKF management, feedback review, and AI configuration for inconsistent wording. +- Split highly technical pages into simple and advanced sections where practical. +- Hide rarely needed expert controls behind expandable advanced sections. +- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values. +- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity. +- Review localization/translation keys if present so simplified wording remains consistent across languages. + ## Done +- 2026-06-17: Completed `/admin/updates` UX pass: viewport-fixed dismissible notifications, auto-dismiss for non-critical results, async core/plugin check actions without page refresh or scroll jumps, in-place update-card data refresh, loading states, and collapsed advanced Manual ZIP fallback below repo update containers. +- 2026-06-17: Completed homepage hero reliability pass: server-side hero validation before save, admin-visible validation errors, home-page fallback message for broken legacy heroes, automatic YouTube/Twitch/Discord embed derivation, correct Twitch parent host at render time, and image/embed conflict handling. +- 2026-06-17: Completed homepage hero builder UX pass: friendlier labels, contextual help text, normal URL paste support, automatic embed filling, per-row readiness messages, and test preview support. +- 2026-06-17: Fixed homepage links to support local Lumi paths such as `/commands`, and made unavailable live-only heroes fall through to lower-priority heroes instead of blocking the homepage. +- 2026-06-17: Completed update-page wording cleanup for common actions such as check, update, restore previous version, disable temporarily, and ZIP rollback wording. - 2026-06-17: Added permanent repo-update architecture for ZIP-origin installs using `data/update-cache/repo`, non-live-git metadata checks, snapshot-copy apply, update-state recording, and preservation of user-owned paths. - 2026-06-17: Separated Lumi AI `tool_info.json` tools from normal plugin update rows and rendered tools under the `lumi_ai` plugin row. diff --git a/src/web/public/homepage-builder.js b/src/web/public/homepage-builder.js index beb3afe..9c42b56 100644 --- a/src/web/public/homepage-builder.js +++ b/src/web/public/homepage-builder.js @@ -4,13 +4,13 @@ const permissions = ["public", "user", "mod", "admin"]; const heroTypes = [ - ["static_image", "Static image"], - ["custom_embed", "Custom embed"], - ["custom_link", "Custom link"], + ["static_image", "Image banner"], + ["custom_embed", "Embedded content"], + ["custom_link", "Featured link"], ["youtube_video", "YouTube video"], - ["youtube_channel", "YouTube channel"], + ["youtube_channel", "YouTube channel live embed"], ["twitch_stream", "Twitch stream"], - ["discord_server_overview", "Discord server overview"], + ["discord_server_overview", "Discord server widget"], ["none", "Fallback message"] ]; const availabilityModes = [ @@ -42,6 +42,12 @@ const span = document.createElement("span"); span.textContent = label; wrapper.append(span, input); + if (options.help) { + const help = document.createElement("small"); + help.className = "hint"; + help.textContent = options.help; + wrapper.append(help); + } return wrapper; }; @@ -117,6 +123,77 @@ } }; + const parseUrl = (value) => { + try { return new URL(value || ""); } catch { return null; } + }; + + const youtubeVideoId = (value) => { + const url = parseUrl(value); + if (!url) return ""; + if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || ""; + const pathMatch = url.pathname.match(/\/(?:embed|shorts|live)\/([^/?#]+)/); + return pathMatch?.[1] || url.searchParams.get("v") || ""; + }; + + const youtubeChannelId = (value) => { + const url = parseUrl(value); + if (!url) return ""; + return url.pathname.match(/\/(?:channel|c|user)\/([^/?#]+)/)?.[1] || ""; + }; + + const twitchChannelName = (value) => { + const url = parseUrl(value); + if (!url || !url.hostname.replace(/^www\./, "").endsWith("twitch.tv")) return ""; + const first = url.pathname.split("/").filter(Boolean)[0] || ""; + return ["videos", "directory", "p"].includes(first) ? "" : first; + }; + + const discordServerId = (value) => { + const raw = String(value || "").trim(); + if (/^\d{10,30}$/.test(raw)) return raw; + const url = parseUrl(raw); + return url?.searchParams.get("id") || ""; + }; + + const deriveEmbedUrl = (type, sourceUrl, platformId = "") => { + const host = window.location.hostname || "localhost"; + if (type === "custom_embed") { + const youtube = youtubeVideoId(sourceUrl); + if (youtube) return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(youtube)}`; + const twitch = twitchChannelName(sourceUrl); + if (twitch) return `https://player.twitch.tv/?channel=${encodeURIComponent(twitch)}&parent=${encodeURIComponent(host)}&muted=true`; + const discord = discordServerId(sourceUrl); + if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`; + return ""; + } + if (type === "youtube_video") { + const id = platformId || youtubeVideoId(sourceUrl); + return id ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` : ""; + } + if (type === "youtube_channel") { + const id = platformId || youtubeChannelId(sourceUrl); + return id ? `https://www.youtube.com/embed/live_stream?channel=${encodeURIComponent(id)}` : ""; + } + if (type === "twitch_stream") { + const channel = platformId || twitchChannelName(sourceUrl); + return channel ? `https://player.twitch.tv/?channel=${encodeURIComponent(channel)}&parent=${encodeURIComponent(host)}&muted=true` : ""; + } + if (type === "discord_server_overview") { + const id = platformId || discordServerId(sourceUrl); + return id ? `https://discord.com/widget?id=${encodeURIComponent(id)}&theme=dark` : ""; + } + return ""; + }; + + const heroValidation = (item) => { + if (item.enabled === false) return { ok: true, message: "Disabled heroes are not shown." }; + if (item.type === "none") return { ok: true, message: "Fallback message is ready." }; + if (item.type === "static_image") return item.image_url ? { ok: true, message: "Image banner is ready." } : { ok: false, message: "Add an image URL before this banner can be shown." }; + if (item.type === "custom_link") return item.source_url ? { ok: true, message: "Featured link is ready." } : { ok: false, message: "Add a link before this hero can be shown." }; + const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id); + return embed ? { ok: true, message: "Embed preview is ready." } : { ok: false, message: "Add a supported URL or platform ID so Lumi can build an embed." }; + }; + const confirmation = async (options) => { if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options); return window.confirm(options.text); @@ -145,17 +222,23 @@ sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index }; } + const type = row.querySelector("[data-field='type']").value; + const sourceUrl = row.querySelector("[data-field='source_url']").value.trim(); + const platformId = row.querySelector("[data-field='video_id']").value.trim(); + const embedInput = row.querySelector("[data-field='embed_url']"); + const derivedEmbed = deriveEmbedUrl(type, sourceUrl, platformId); + if (!embedInput.value.trim() && derivedEmbed) embedInput.value = derivedEmbed; return { enabled: row.querySelector("[data-field='enabled']").checked, - type: row.querySelector("[data-field='type']").value, + type, title: row.querySelector("[data-field='title']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(), priority: Number(row.querySelector("[data-field='priority']").value) || index, permission: row.querySelector("[data-field='permission']").value, - source_url: row.querySelector("[data-field='source_url']").value.trim(), + source_url: sourceUrl, image_url: row.querySelector("[data-field='image_url']").value.trim(), - embed_url: row.querySelector("[data-field='embed_url']").value.trim(), - video_id: row.querySelector("[data-field='video_id']").value.trim(), + embed_url: embedInput.value.trim(), + video_id: platformId, availability_mode: row.querySelector("[data-field='availability_mode']").value, autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value, duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0, @@ -166,6 +249,7 @@ source.value = JSON.stringify(next, null, 2); source.dispatchEvent(new Event("input", { bubbles: true })); renderPreviews(); + renderValidation(); }; const addField = (row, labelText, element, name, options = {}) => { @@ -266,28 +350,28 @@ row.append(header); if (kind === "links") { - addField(row, "Label", textInput(item.label, "Commands"), "label"); - addField(row, "Description", textInput(item.description, "Open command list"), "description"); - addField(row, "URL", textInput(item.url, "/commands"), "url"); - const iconMode = addField(row, "Icon mode", selectInput(item.icon_mode || "favicon", [["favicon", "Fetched favicon/logo"], ["manual", "Manual icon URL"], ["letter", "Fallback letter"]]), "icon_mode"); - addField(row, "Manual icon/logo URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon" }); - addField(row, "Fetched favicon/logo preview", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon" }); - addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); - addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order"); + addField(row, "Button text", textInput(item.label, "Commands"), "label", { help: "Short label shown on the homepage card." }); + addField(row, "Short description", textInput(item.description, "Open command list"), "description", { help: "One sentence explaining where the link goes." }); + addField(row, "Link URL", textInput(item.url, "/commands"), "url", { help: "Use a full public URL or a local Lumi path such as /commands." }); + const iconMode = addField(row, "Icon source", selectInput(item.icon_mode || "favicon", [["favicon", "Use site icon when available"], ["manual", "Use my image URL"], ["letter", "Use first letter"]]), "icon_mode", { help: "Choose how the link card icon should be displayed." }); + addField(row, "Custom icon image URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon", help: "Optional square image or logo URL." }); + addField(row, "Detected site icon URL", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon", help: "Optional favicon URL if automatic detection found one." }); + addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission"); + addField(row, "Display order", numberInput(item.sort_order ?? index, 0), "sort_order"); iconMode.addEventListener("change", () => updateLinkRelevance(row)); } else { - const typeSelect = addField(row, "Type", selectInput(item.type || "static_image", heroTypes), "type"); - addField(row, "Title", textInput(item.title, "Featured stream"), "title"); - addField(row, "Description", textInput(item.description, "What's happening now"), "description"); - addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority"); - addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); - addField(row, "Source URL or platform ID", textInput(item.source_url, "https://..."), "source_url", { relevance: "source" }); - addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url", { relevance: "image" }); - addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url", { relevance: "embed" }); - addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" }); - addField(row, "Availability mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" }); - addField(row, "Autoplay mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" }); - addField(row, "Duration timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video" }); + const typeSelect = addField(row, "Content type", selectInput(item.type || "static_image", heroTypes), "type", { help: "Choose what this hero should display." }); + addField(row, "Headline", textInput(item.title, "Featured stream"), "title"); + addField(row, "Short description", textInput(item.description, "What's happening now"), "description"); + addField(row, "Display priority", numberInput(item.priority ?? index, 0), "priority", { help: "Lower numbers are checked first." }); + addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission"); + addField(row, "Normal URL or platform link", textInput(item.source_url, "Paste a YouTube, Twitch, Discord, website, or image URL"), "source_url", { relevance: "source", help: "Paste the normal page URL. Lumi will build an embed URL when possible." }); + addField(row, "Image URL", textInput(item.image_url, "https://example.com/banner.png"), "image_url", { relevance: "image", help: "Direct image URL for image banner heroes." }); + addField(row, "Embed URL", textInput(item.embed_url, "Filled automatically for supported services"), "embed_url", { relevance: "embed", help: "Optional advanced field. Leave blank to auto-convert common service URLs." }); + addField(row, "Platform ID", textInput(item.video_id, "Optional video, channel, or server ID"), "video_id", { relevance: "video", help: "Use only if the normal URL cannot be detected." }); + addField(row, "Availability", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video", help: "Live-only heroes hide unless live state says they are available." }); + addField(row, "Autoplay", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" }); + addField(row, "Display timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video", help: "Optional duration hint for rotating heroes." }); addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" }); typeSelect.addEventListener("change", () => updateHeroRelevance(row)); } @@ -296,6 +380,12 @@ preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero"; preview.dataset.homepagePreview = ""; row.append(preview); + if (kind === "heroes") { + const validation = document.createElement("div"); + validation.className = "homepage-builder-validation"; + validation.dataset.homepageValidation = ""; + row.append(validation); + } const actions = document.createElement("div"); actions.className = "homepage-builder-actions"; @@ -334,6 +424,14 @@ rows.splice(index, 1); render(); }); + if (kind === "heroes") { + const test = document.createElement("button"); + test.type = "button"; + test.className = "button subtle"; + test.textContent = "Test preview"; + test.addEventListener("click", () => testHeroPreview(row)); + actions.append(test); + } actions.append(up, down, duplicate, remove); row.append(actions); row.addEventListener("input", sync); @@ -365,4 +463,51 @@ .replaceAll('"', """) .replaceAll("'", "'"); } + + function renderValidation() { + builders.forEach((builder) => { + if (builder.dataset.homepageBuilder !== "heroes") return; + builder.querySelectorAll("[data-homepage-row]").forEach((row) => { + const target = row.querySelector("[data-homepage-validation]"); + if (!target) return; + const item = { + enabled: row.querySelector("[data-field='enabled']")?.checked, + type: row.querySelector("[data-field='type']")?.value, + source_url: row.querySelector("[data-field='source_url']")?.value.trim(), + image_url: row.querySelector("[data-field='image_url']")?.value.trim(), + embed_url: row.querySelector("[data-field='embed_url']")?.value.trim(), + video_id: row.querySelector("[data-field='video_id']")?.value.trim() + }; + const status = heroValidation(item); + target.classList.toggle("danger", !status.ok); + target.classList.toggle("success", status.ok); + target.textContent = status.message; + }); + }); + } + + function testHeroPreview(row) { + const type = row.querySelector("[data-field='type']")?.value || "static_image"; + const source = row.querySelector("[data-field='source_url']")?.value.trim() || ""; + const image = row.querySelector("[data-field='image_url']")?.value.trim() || ""; + const embedInput = row.querySelector("[data-field='embed_url']"); + const platformId = row.querySelector("[data-field='video_id']")?.value.trim() || ""; + const embed = embedInput?.value.trim() || deriveEmbedUrl(type, source, platformId); + if (embedInput && !embedInput.value.trim() && embed) embedInput.value = embed; + const preview = row.querySelector("[data-homepage-preview]"); + if (!preview) return; + if (type === "static_image" && image) { + preview.innerHTML = `Image preview`; + return; + } + if (embed) { + preview.innerHTML = `Embed preview. Some services block previews until saved on the public host.`; + return; + } + if (source) { + window.open(source, "_blank", "noopener,noreferrer"); + return; + } + preview.innerHTML = `Preview unavailableAdd a supported URL, image, or platform ID first.`; + } })(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 46c879d..d6a3fe2 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -746,10 +746,87 @@ input[type="color"] { color: var(--lumi-text-muted); } +.homepage-builder-validation { + grid-column: 1 / -1; + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); + color: var(--lumi-text-muted); + font-weight: 700; +} + +.homepage-builder-validation.success { + border-color: color-mix(in srgb, var(--lumi-success) 50%, var(--lumi-border)); +} + +.homepage-builder-validation.danger { + border-color: color-mix(in srgb, var(--lumi-danger) 55%, var(--lumi-border)); + color: var(--lumi-danger); +} + +.homepage-builder-preview-media { + width: 100%; + min-height: 12rem; + max-height: 24rem; + border: 0; + border-radius: var(--lumi-radius-md); + object-fit: cover; + background: var(--lumi-surface-2); +} + +.homepage-hero-error { + display: grid; + place-items: center; + gap: var(--lumi-space-2); + min-height: 12rem; + padding: var(--lumi-space-4); + border: 1px dashed color-mix(in srgb, var(--lumi-warning) 55%, var(--lumi-border)); + color: var(--lumi-text-muted); + text-align: center; +} + .update-recovery-banner { border-left: 4px solid var(--lumi-danger); } +.update-notice-stack { + position: fixed; + top: var(--lumi-space-4); + left: 50%; + z-index: 120; + display: grid; + gap: var(--lumi-space-2); + width: min(42rem, calc(100vw - 2rem)); + pointer-events: none; + transform: translateX(-50%); +} + +.update-notice { + display: flex; + align-items: center; + justify-content: space-between; + 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); + box-shadow: var(--lumi-shadow-md); + pointer-events: auto; +} + +.update-notice.success { + border-color: color-mix(in srgb, var(--lumi-success) 50%, var(--lumi-border)); +} + +.update-notice.warning { + border-color: color-mix(in srgb, var(--lumi-warning) 50%, var(--lumi-border)); +} + +.update-notice.danger { + border-color: color-mix(in srgb, var(--lumi-danger) 55%, var(--lumi-border)); +} + .update-detail-grid { display: grid; gap: var(--lumi-space-4); diff --git a/src/web/server.js b/src/web/server.js index 2353757..54efea4 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1697,6 +1697,14 @@ function safeExternalUrl(value) { } } +function safeHomepageLinkUrl(value) { + const raw = String(value || "").trim(); + if (raw.startsWith("/") && !raw.startsWith("//")) { + return raw; + } + return safeExternalUrl(raw); +} + function permissionAllows(user, permission = "public") { const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public"; return role === "public" ? true : hasAccess(user, role); @@ -1704,6 +1712,7 @@ function permissionAllows(user, permission = "public") { function fallbackIconForUrl(url) { try { + if (String(url || "").startsWith("/")) return "L"; const host = new URL(url).hostname.replace(/^www\./, ""); return host.slice(0, 1).toUpperCase(); } catch { @@ -1716,7 +1725,7 @@ function homepageLinksForUser(user) { .filter((item) => item && item.enabled !== false) .filter((item) => permissionAllows(user, item.permission)) .map((item, index) => { - const url = safeExternalUrl(item.url); + const url = safeHomepageLinkUrl(item.url); if (!url) return null; const iconMode = String(item.icon_mode || "").trim(); const iconUrl = @@ -1739,19 +1748,20 @@ function homepageLinksForUser(user) { .sort((a, b) => a.sort_order - b.sort_order); } -function homepageHeroForUser(user) { +function homepageHeroForUser(user, req = null) { 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); + const hero = normalizeHomepageHero(item, { parentHost: homepageEmbedParent(req) }); if (hero?.available) return hero; + if (hero?.render_error && hasAccess(user, "admin")) return hero; } return null; } -function normalizeHomepageHero(item) { +function normalizeHomepageHero(item, options = {}) { const type = String(item.type || "none"); if (type === "none") { return item.fallback_behavior === "message" @@ -1759,7 +1769,7 @@ function normalizeHomepageHero(item) { : null; } const sourceUrl = safeExternalUrl(item.source_url); - const embedUrl = safeExternalUrl(item.embed_url); + const embedUrl = safeExternalUrl(item.embed_url) || deriveHomepageEmbedUrl(type, item, options); const imageUrl = safeExternalUrl(item.image_url); const title = String(item.title || "Featured content").slice(0, 120); const description = String(item.description || "").slice(0, 500); @@ -1772,21 +1782,131 @@ function normalizeHomepageHero(item) { } 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 { type, available: Boolean(embedUrl), title, description, embed_url: embedUrl, source_url: sourceUrl, render_error: embedUrl ? "" : heroRenderError(type) }; } - return null; + return { + type, + available: false, + title, + description, + source_url: sourceUrl, + render_error: heroRenderError(type) + }; } 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") || ""; + const pathMatch = url.pathname.match(/\/(?:embed|shorts|live)\/([^/?#]+)/); + return pathMatch?.[1] || url.searchParams.get("v") || ""; } catch { return ""; } } +function youtubeChannelId(value) { + try { + const url = new URL(value || ""); + const match = url.pathname.match(/\/(?:channel|c|user)\/([^/?#]+)/); + return match?.[1] || ""; + } catch { + return ""; + } +} + +function twitchChannelName(value) { + try { + const url = new URL(value || ""); + const host = url.hostname.replace(/^www\./, ""); + if (!host.endsWith("twitch.tv")) return ""; + const first = url.pathname.split("/").filter(Boolean)[0] || ""; + return ["videos", "directory", "p"].includes(first) ? "" : first; + } catch { + return ""; + } +} + +function discordServerId(value) { + const raw = String(value || "").trim(); + if (/^\d{10,30}$/.test(raw)) return raw; + try { + const url = new URL(raw); + return url.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +function deriveHomepageEmbedUrl(type, item, options = {}) { + const sourceUrl = safeExternalUrl(item.source_url); + const parentHost = options.parentHost || "localhost"; + if (type === "custom_embed") { + if (!sourceUrl) return ""; + const youtube = youtubeVideoId(sourceUrl); + if (youtube) return `https://www.youtube-nocookie.com/embed/${youtube}`; + const twitch = twitchChannelName(sourceUrl); + if (twitch) return twitchEmbedUrl(twitch, parentHost); + const discord = discordServerId(sourceUrl); + if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`; + return ""; + } + if (type === "youtube_video") { + const video = item.video_id || youtubeVideoId(sourceUrl); + return video ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(video)}` : ""; + } + if (type === "youtube_channel") { + const channel = item.video_id || youtubeChannelId(sourceUrl); + return channel ? `https://www.youtube.com/embed/live_stream?channel=${encodeURIComponent(channel)}` : ""; + } + if (type === "twitch_stream") { + const channel = item.video_id || twitchChannelName(sourceUrl); + return channel ? twitchEmbedUrl(channel, parentHost) : ""; + } + if (type === "discord_server_overview") { + const server = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl); + return server ? `https://discord.com/widget?id=${encodeURIComponent(server)}&theme=dark` : ""; + } + return ""; +} + +function homepageEmbedParent(req) { + const host = String(req?.hostname || req?.get?.("host") || "localhost") + .split(":")[0] + .trim(); + return host || "localhost"; +} + +function twitchEmbedUrl(channel, parentHost) { + return `https://player.twitch.tv/?channel=${encodeURIComponent(channel)}&parent=${encodeURIComponent(parentHost)}&muted=true`; +} + +function heroRenderError(type) { + const labels = { + static_image: "Add an image URL before this image hero can be displayed.", + custom_link: "Add a public link before this link hero can be displayed.", + custom_embed: "Add a supported YouTube, Twitch, Discord, or direct embed URL before this hero can be displayed.", + youtube_video: "Add a YouTube video URL or video ID before this hero can be displayed.", + youtube_channel: "Add a YouTube channel URL or channel ID before this hero can be displayed.", + twitch_stream: "Add a Twitch channel URL or channel name before this hero can be displayed.", + discord_server_overview: "Add a Discord server widget URL or server ID before this hero can be displayed." + }; + return labels[type] || "This hero is missing the required URL or ID."; +} + +function validateHomepageHeroEntries(entries) { + const errors = []; + entries.forEach((item, index) => { + if (!item || item.enabled === false) return; + const hero = normalizeHomepageHero({ ...item, availability_mode: "always" }, { parentHost: "localhost" }); + if (hero?.available) return; + if (item.type === "none" && item.fallback_behavior !== "hide") return; + const label = item.title || `hero ${index + 1}`; + errors.push(`${label}: ${hero?.render_error || heroRenderError(item.type || "none")}`); + }); + return errors; +} + function getDiscordSettings() { return { discord_client_id: getSetting("discord_client_id", ""), @@ -2349,7 +2469,7 @@ function createWebServer({ loadPlugins, discordClient }) { res.render("home", { title: "Home", homepageLinks: homepageLinksForUser(req.session.user), - homepageHero: homepageHeroForUser(req.session.user) + homepageHero: homepageHeroForUser(req.session.user, req) }); }); @@ -3936,6 +4056,13 @@ function createWebServer({ loadPlugins, discordClient }) { try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) throw new Error("Expected an array."); + if (field === "homepage_hero_entries") { + const heroErrors = validateHomepageHeroEntries(parsed); + if (heroErrors.length) { + setFlash(req, "error", `Homepage hero cannot be saved yet. ${heroErrors.slice(0, 3).join(" ")}`); + return res.redirect("/admin/settings"); + } + } setSetting(field, parsed); } catch (error) { setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`); diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index b146436..2769f1b 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -38,7 +38,7 @@

Updates

-

Upload bot or plugin ZIP updates and review snapshots.

+

Check repository updates, review backups, and use ZIP recovery only when needed.

Manage updates
@@ -90,11 +90,11 @@ ] }) %> -
+ <%- include("partials/state-button", { type: "submit", states: [ - { id: "idle", text: "Update from git" }, + { id: "idle", text: "Update from repository" }, { id: "loading", text: "Updating", spinner: true }, { id: "success", text: "Updated" } ] diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index bf76d8c..53d568f 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -32,9 +32,9 @@
- + -

Use a remote alias such as origin or a repository URL. The .git suffix is optional.

+

Use the Lumi repository URL. Advanced installs may use a configured remote alias such as origin. The .git suffix is optional.

@@ -68,15 +68,15 @@ <%- include("partials/state-button", { type: "submit", classes: "subtle", - attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"", + attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from repository\" data-confirm-text=\"Download the recommended update from the configured repository, create a backup, apply it, and restart Lumi if the update succeeds.\" data-confirm-label=\"Update from repository\"", states: [ - { id: "idle", text: "Update from git" }, + { id: "idle", text: "Update from repository" }, { id: "loading", text: "Updating", spinner: true }, { id: "success", text: "Updated" } ] }) %> -
-

Git update checks use the configured remote and branch.

+ +

Repository update checks use the configured target and selected channel.

diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 14b475b..eddf340 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -12,12 +12,12 @@ const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success"; const toolBadgeText = (item) => item?.blocked ? "Blocked" : item?.installed === false ? "Available" : item?.update_available ? "Update available" : "Current"; const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : []; - const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target"; + const applyLabel = (item) => item?.unversioned_update ? "Review and update" : "Use recommended version"; const applyConfirmText = (item, label) => { - const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`; + const base = `${label}: create a backup, prepare recovery, apply ${item?.safe_target_version || "the selected update"}, and verify before finishing.`; if (!item?.unversioned_update) return base; const warnings = (item.warnings || []).join(" "); - return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`; + return `${base} Warning: this update is to or from an unversioned install or update, so Lumi cannot fully verify version order, changelog range, or restore safety. ${warnings}`; }; %> @@ -25,7 +25,7 @@ <%- include("partials/page-header", { eyebrow: "Maintenance", pageTitle: "Updates", - description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback." + description: "Repository-first core and plugin updates with backups, recovery markers, restore actions, and advanced ZIP fallback." }) %> <% if (updateStatusError) { %>
Update metadata could not be loaded: <%= updateStatusError %>
@@ -43,34 +43,7 @@

Stable checks read repo metadata from main. Experimental branches are considered only when selected here.

-
-

Manual ZIP updates

-

These upload paths stay available even when repository metadata cannot be loaded.

-
-
-

Core ZIP

-

Use a full core ZIP or a patch ZIP containing files relative to the repo root.

- -
- - -
- - - -
-
-

Plugin ZIP

-

Use this fallback when plugin metadata is unavailable. Plugin ZIPs must include a valid plugin.json.

-
-
- - -
-
-
-
-
+
<% if (recovery.has_incomplete_marker || marker) { %>
@@ -104,28 +77,28 @@
<% } %> -
+
> Core - Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %> + Current <%= core?.current_version || "unknown" %> · Recommended <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %> - <%= badgeText(core) %> + <%= badgeText(core) %>
<% if (core) { %>
-
Current<%= core.current_version %>
-
Safe target<%= core.safe_target_version || "None" %>
-
Latest<%= core.latest_available_version %>
-
Source branch<%= core.source_branch %>
+
Current<%= core.current_version %>
+
Recommended version<%= core.safe_target_version || "None" %>
+
Latest found<%= core.latest_available_version %>
+
Update source<%= core.source_branch %>
Size change<%= core.size_delta_label %>
Snapshot<%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %>
-

<%= core.version_description %>

+

<%= core.version_description %>

<% if (core.warnings?.length) { %>
Warnings
    <% core.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> - <% if (core.unversioned_update) { %>
Manual confirmation required

This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> + <% if (core.unversioned_update) { %>
Manual confirmation required

This core update is to or from an unversioned state. It remains available, but restore safety and version order cannot be fully verified from metadata.

<% } %> <% if (core.dangers?.length) { %>
Dangers
    <% core.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.requirements?.length) { %>
Requirements
    <% core.requirements.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.migration_notes) { %>
Migration notes

<%= core.migration_notes %>

<% } %> @@ -144,32 +117,20 @@
- +
" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>"> - +
<% if (core.snapshot.available) { %> -
+ - +
<% } %>
-
- Show advanced ZIP update options -
ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
-
-
- - -
- - -
-
<% } %>
@@ -193,18 +154,18 @@ <%= plugin.name %> - <%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %> + <%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %> - <%= badgeText(plugin) %> + <%= badgeText(plugin) %>
-
Current<%= plugin.current_version %>
-
Safe target<%= plugin.safe_target_version || "None" %>
-
Latest<%= plugin.latest_available_version %>
+
Current<%= plugin.current_version %>
+
Recommended version<%= plugin.safe_target_version || "None" %>
+
Latest found<%= plugin.latest_available_version %>
Snapshot<%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %>
-

<%= plugin.version_description %>

+

<%= plugin.version_description %>

<% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %> <% if (plugin.id === "lumi_ai" && tools.length) { %> <% const toolSummary = plugin.tools_summary || {}; %> @@ -252,7 +213,7 @@ <% } %> <% if (plugin.warnings?.length) { %>
Warnings
    <% plugin.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> - <% if (plugin.unversioned_update) { %>
Manual confirmation required

This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> + <% if (plugin.unversioned_update) { %>
Manual confirmation required

This plugin update is to or from an unversioned state. It remains available, but restore safety and version order cannot be fully verified from metadata.

<% } %> <% if (plugin.dangers?.length) { %>
Dangers
    <% plugin.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (plugin.migration_notes) { %>
Migration notes

<%= plugin.migration_notes %>

<% } %>

Changelog to target

@@ -268,36 +229,25 @@
- +
" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>"> - +
<% if (plugin.snapshot.available) { %> -
+ - +
<% } %> <% if (plugin.installed !== false) { %>
- +
<% } %>
-
- Show advanced plugin ZIP options -
Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
-
-
- - -
- -
-
<% }) %> @@ -305,6 +255,45 @@
+
+
+ + + Manual ZIP updates + Advanced fallback for recovery, offline installs, or when repository updates fail. + + Advanced + +
+

Repository updates are the recommended path. ZIP uploads still create backups and recovery markers, but they can bypass repository metadata and compatibility checks unless the ZIP includes valid manifest data.

+
+
+

Core ZIP

+

Use a full core ZIP or a patch ZIP containing files relative to the Lumi install root.

+
+
+ + +
+ + +
+
+
+

Plugin ZIP

+

Use when plugin repository metadata is unavailable. Plugin ZIPs must include a valid plugin.json.

+
+
+ + +
+
+
+
+
+
+
+

Live Progress

@@ -334,7 +323,7 @@ <%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %> <%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %> <%= snap.update_method || "snapshot" %> - <%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %> + <%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Previous-version restore allowed" %> <%= new Date(snap.createdAt).toLocaleString() %> <% }) %> @@ -343,4 +332,101 @@
<% } %>
+ <%- include("partials/layout-bottom") %> diff --git a/src/web/views/home.ejs b/src/web/views/home.ejs index 21cf90a..40904a5 100644 --- a/src/web/views/home.ejs +++ b/src/web/views/home.ejs @@ -19,6 +19,11 @@ <% } else if (homepageHero.embed_url) { %> + <% } else if (homepageHero.render_error) { %> +
+ Hero unavailable + <%= homepageHero.render_error %> +
<% } %>
<% } %>