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 @@
Upload bot or plugin ZIP updates and review snapshots.
+Check repository updates, review backups, and use ZIP recovery only when needed.
Manage updates