improve update and homepage hero ux

This commit is contained in:
Franz Rolfsvaag 2026-06-17 12:35:17 +02:00
parent 1c329bd551
commit f99dbed310
8 changed files with 609 additions and 128 deletions

41
TODO.md
View File

@ -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. - 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. - 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 ## 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: 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. - 2026-06-17: Separated Lumi AI `tool_info.json` tools from normal plugin update rows and rendered tools under the `lumi_ai` plugin row.

View File

@ -4,13 +4,13 @@
const permissions = ["public", "user", "mod", "admin"]; const permissions = ["public", "user", "mod", "admin"];
const heroTypes = [ const heroTypes = [
["static_image", "Static image"], ["static_image", "Image banner"],
["custom_embed", "Custom embed"], ["custom_embed", "Embedded content"],
["custom_link", "Custom link"], ["custom_link", "Featured link"],
["youtube_video", "YouTube video"], ["youtube_video", "YouTube video"],
["youtube_channel", "YouTube channel"], ["youtube_channel", "YouTube channel live embed"],
["twitch_stream", "Twitch stream"], ["twitch_stream", "Twitch stream"],
["discord_server_overview", "Discord server overview"], ["discord_server_overview", "Discord server widget"],
["none", "Fallback message"] ["none", "Fallback message"]
]; ];
const availabilityModes = [ const availabilityModes = [
@ -42,6 +42,12 @@
const span = document.createElement("span"); const span = document.createElement("span");
span.textContent = label; span.textContent = label;
wrapper.append(span, input); wrapper.append(span, input);
if (options.help) {
const help = document.createElement("small");
help.className = "hint";
help.textContent = options.help;
wrapper.append(help);
}
return wrapper; 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) => { const confirmation = async (options) => {
if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options); if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options);
return window.confirm(options.text); return window.confirm(options.text);
@ -145,17 +222,23 @@
sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index 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 { return {
enabled: row.querySelector("[data-field='enabled']").checked, enabled: row.querySelector("[data-field='enabled']").checked,
type: row.querySelector("[data-field='type']").value, type,
title: row.querySelector("[data-field='title']").value.trim(), title: row.querySelector("[data-field='title']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(),
priority: Number(row.querySelector("[data-field='priority']").value) || index, priority: Number(row.querySelector("[data-field='priority']").value) || index,
permission: row.querySelector("[data-field='permission']").value, 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(), image_url: row.querySelector("[data-field='image_url']").value.trim(),
embed_url: row.querySelector("[data-field='embed_url']").value.trim(), embed_url: embedInput.value.trim(),
video_id: row.querySelector("[data-field='video_id']").value.trim(), video_id: platformId,
availability_mode: row.querySelector("[data-field='availability_mode']").value, availability_mode: row.querySelector("[data-field='availability_mode']").value,
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value, autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0, duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
@ -166,6 +249,7 @@
source.value = JSON.stringify(next, null, 2); source.value = JSON.stringify(next, null, 2);
source.dispatchEvent(new Event("input", { bubbles: true })); source.dispatchEvent(new Event("input", { bubbles: true }));
renderPreviews(); renderPreviews();
renderValidation();
}; };
const addField = (row, labelText, element, name, options = {}) => { const addField = (row, labelText, element, name, options = {}) => {
@ -266,28 +350,28 @@
row.append(header); row.append(header);
if (kind === "links") { if (kind === "links") {
addField(row, "Label", textInput(item.label, "Commands"), "label"); addField(row, "Button text", textInput(item.label, "Commands"), "label", { help: "Short label shown on the homepage card." });
addField(row, "Description", textInput(item.description, "Open command list"), "description"); addField(row, "Short description", textInput(item.description, "Open command list"), "description", { help: "One sentence explaining where the link goes." });
addField(row, "URL", textInput(item.url, "/commands"), "url"); 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 mode", selectInput(item.icon_mode || "favicon", [["favicon", "Fetched favicon/logo"], ["manual", "Manual icon URL"], ["letter", "Fallback letter"]]), "icon_mode"); 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, "Manual icon/logo URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon" }); 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, "Fetched favicon/logo preview", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon" }); 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, "Permission", selectInput(item.permission || "public", permissions), "permission"); addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order"); addField(row, "Display order", numberInput(item.sort_order ?? index, 0), "sort_order");
iconMode.addEventListener("change", () => updateLinkRelevance(row)); iconMode.addEventListener("change", () => updateLinkRelevance(row));
} else { } else {
const typeSelect = addField(row, "Type", selectInput(item.type || "static_image", heroTypes), "type"); const typeSelect = addField(row, "Content type", selectInput(item.type || "static_image", heroTypes), "type", { help: "Choose what this hero should display." });
addField(row, "Title", textInput(item.title, "Featured stream"), "title"); addField(row, "Headline", textInput(item.title, "Featured stream"), "title");
addField(row, "Description", textInput(item.description, "What's happening now"), "description"); addField(row, "Short description", textInput(item.description, "What's happening now"), "description");
addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority"); addField(row, "Display priority", numberInput(item.priority ?? index, 0), "priority", { help: "Lower numbers are checked first." });
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission"); addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Source URL or platform ID", textInput(item.source_url, "https://..."), "source_url", { relevance: "source" }); 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://.../image.png"), "image_url", { relevance: "image" }); 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, "https://.../embed"), "embed_url", { relevance: "embed" }); 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, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" }); 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 mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" }); 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 mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" }); addField(row, "Autoplay", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
addField(row, "Duration timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { 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" }); addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
typeSelect.addEventListener("change", () => updateHeroRelevance(row)); typeSelect.addEventListener("change", () => updateHeroRelevance(row));
} }
@ -296,6 +380,12 @@
preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero"; preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero";
preview.dataset.homepagePreview = ""; preview.dataset.homepagePreview = "";
row.append(preview); 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"); const actions = document.createElement("div");
actions.className = "homepage-builder-actions"; actions.className = "homepage-builder-actions";
@ -334,6 +424,14 @@
rows.splice(index, 1); rows.splice(index, 1);
render(); 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); actions.append(up, down, duplicate, remove);
row.append(actions); row.append(actions);
row.addEventListener("input", sync); row.addEventListener("input", sync);
@ -365,4 +463,51 @@
.replaceAll('"', """) .replaceAll('"', """)
.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 = `<img src="${escapeHtml(image)}" alt="" class="homepage-builder-preview-media"><small>Image preview</small>`;
return;
}
if (embed) {
preview.innerHTML = `<iframe src="${escapeHtml(embed)}" title="Homepage hero preview" class="homepage-builder-preview-media" loading="lazy" allowfullscreen></iframe><small>Embed preview. Some services block previews until saved on the public host.</small>`;
return;
}
if (source) {
window.open(source, "_blank", "noopener,noreferrer");
return;
}
preview.innerHTML = `<strong>Preview unavailable</strong><small>Add a supported URL, image, or platform ID first.</small>`;
}
})(); })();

View File

@ -746,10 +746,87 @@ input[type="color"] {
color: var(--lumi-text-muted); 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 { .update-recovery-banner {
border-left: 4px solid var(--lumi-danger); 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 { .update-detail-grid {
display: grid; display: grid;
gap: var(--lumi-space-4); gap: var(--lumi-space-4);

View File

@ -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") { function permissionAllows(user, permission = "public") {
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public"; const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
return role === "public" ? true : hasAccess(user, role); return role === "public" ? true : hasAccess(user, role);
@ -1704,6 +1712,7 @@ function permissionAllows(user, permission = "public") {
function fallbackIconForUrl(url) { function fallbackIconForUrl(url) {
try { try {
if (String(url || "").startsWith("/")) return "L";
const host = new URL(url).hostname.replace(/^www\./, ""); const host = new URL(url).hostname.replace(/^www\./, "");
return host.slice(0, 1).toUpperCase(); return host.slice(0, 1).toUpperCase();
} catch { } catch {
@ -1716,7 +1725,7 @@ function homepageLinksForUser(user) {
.filter((item) => item && item.enabled !== false) .filter((item) => item && item.enabled !== false)
.filter((item) => permissionAllows(user, item.permission)) .filter((item) => permissionAllows(user, item.permission))
.map((item, index) => { .map((item, index) => {
const url = safeExternalUrl(item.url); const url = safeHomepageLinkUrl(item.url);
if (!url) return null; if (!url) return null;
const iconMode = String(item.icon_mode || "").trim(); const iconMode = String(item.icon_mode || "").trim();
const iconUrl = const iconUrl =
@ -1739,19 +1748,20 @@ function homepageLinksForUser(user) {
.sort((a, b) => a.sort_order - b.sort_order); .sort((a, b) => a.sort_order - b.sort_order);
} }
function homepageHeroForUser(user) { function homepageHeroForUser(user, req = null) {
const entries = parseJsonSetting("homepage_hero_entries", []) const entries = parseJsonSetting("homepage_hero_entries", [])
.filter((item) => item && item.enabled !== false) .filter((item) => item && item.enabled !== false)
.filter((item) => permissionAllows(user, item.permission)) .filter((item) => permissionAllows(user, item.permission))
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0)); .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
for (const item of entries) { for (const item of entries) {
const hero = normalizeHomepageHero(item); const hero = normalizeHomepageHero(item, { parentHost: homepageEmbedParent(req) });
if (hero?.available) return hero; if (hero?.available) return hero;
if (hero?.render_error && hasAccess(user, "admin")) return hero;
} }
return null; return null;
} }
function normalizeHomepageHero(item) { function normalizeHomepageHero(item, options = {}) {
const type = String(item.type || "none"); const type = String(item.type || "none");
if (type === "none") { if (type === "none") {
return item.fallback_behavior === "message" return item.fallback_behavior === "message"
@ -1759,7 +1769,7 @@ function normalizeHomepageHero(item) {
: null; : null;
} }
const sourceUrl = safeExternalUrl(item.source_url); 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 imageUrl = safeExternalUrl(item.image_url);
const title = String(item.title || "Featured content").slice(0, 120); const title = String(item.title || "Featured content").slice(0, 120);
const description = String(item.description || "").slice(0, 500); 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 (["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; 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) { function youtubeVideoId(value) {
try { try {
const url = new URL(value || ""); const url = new URL(value || "");
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1); 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 { } catch {
return ""; 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() { function getDiscordSettings() {
return { return {
discord_client_id: getSetting("discord_client_id", ""), discord_client_id: getSetting("discord_client_id", ""),
@ -2349,7 +2469,7 @@ function createWebServer({ loadPlugins, discordClient }) {
res.render("home", { res.render("home", {
title: "Home", title: "Home",
homepageLinks: homepageLinksForUser(req.session.user), 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 { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error("Expected an array."); 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); setSetting(field, parsed);
} catch (error) { } catch (error) {
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`); setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);

View File

@ -38,7 +38,7 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Updates</h2> <h2>Updates</h2>
<p>Upload bot or plugin ZIP updates and review snapshots.</p> <p>Check repository updates, review backups, and use ZIP recovery only when needed.</p>
<a href="/admin/updates" class="link">Manage updates</a> <a href="/admin/updates" class="link">Manage updates</a>
</div> </div>
</div> </div>
@ -90,11 +90,11 @@
] ]
}) %> }) %>
</form> </form>
<form method="post" action="/admin/update" class="inline-form" 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"> <form method="post" action="/admin/update" class="inline-form" 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">
<%- include("partials/state-button", { <%- include("partials/state-button", {
type: "submit", type: "submit",
states: [ states: [
{ id: "idle", text: "Update from git" }, { id: "idle", text: "Update from repository" },
{ id: "loading", text: "Updating", spinner: true }, { id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" } { id: "success", text: "Updated" }
] ]

View File

@ -32,9 +32,9 @@
<input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" /> <input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" />
</div> </div>
<div class="field"> <div class="field">
<label>Git remote / repository target</label> <label>Repository target</label>
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" placeholder="origin or https://git.example/owner/repo(.git)" /> <input name="git_remote" value="<%= settings.git_remote || 'origin' %>" placeholder="origin or https://git.example/owner/repo(.git)" />
<p class="hint">Use a remote alias such as <code>origin</code> or a repository URL. The <code>.git</code> suffix is optional.</p> <p class="hint">Use the Lumi repository URL. Advanced installs may use a configured remote alias such as <code>origin</code>. The <code>.git</code> suffix is optional.</p>
</div> </div>
<div class="field"> <div class="field">
<label>Update branch</label> <label>Update branch</label>
@ -68,15 +68,15 @@
<%- include("partials/state-button", { <%- include("partials/state-button", {
type: "submit", type: "submit",
classes: "subtle", 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: [ states: [
{ id: "idle", text: "Update from git" }, { id: "idle", text: "Update from repository" },
{ id: "loading", text: "Updating", spinner: true }, { id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" } { id: "success", text: "Updated" }
] ]
}) %> }) %>
</div> </div>
<p class="hint">Git update checks use the configured remote and branch.</p> <p class="hint">Repository update checks use the configured target and selected channel.</p>
</div> </div>
<div class="field full"> <div class="field full">

View File

@ -12,12 +12,12 @@
const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success"; 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 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 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 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; if (!item?.unversioned_update) return base;
const warnings = (item.warnings || []).join(" "); 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", { <%- include("partials/page-header", {
eyebrow: "Maintenance", eyebrow: "Maintenance",
pageTitle: "Updates", 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) { %> <% if (updateStatusError) { %>
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div> <div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
@ -43,34 +43,7 @@
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p> <p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
</section> </section>
<section class="card"> <div class="update-notice-stack" data-update-notices aria-live="polite"></div>
<h2>Manual ZIP updates</h2>
<p class="hint">These upload paths stay available even when repository metadata cannot be loaded.</p>
<div class="grid">
<div class="card">
<h3>Core ZIP</h3>
<p class="hint">Use a full core ZIP or a patch ZIP containing files relative to the repo root.</p>
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload core ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</div>
<div class="card">
<h3>Plugin ZIP</h3>
<p class="hint">Use this fallback when plugin metadata is unavailable. Plugin ZIPs must include a valid <code>plugin.json</code>.</p>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin ZIP</button>
</div>
</form>
</div>
</div>
</section>
<% if (recovery.has_incomplete_marker || marker) { %> <% if (recovery.has_incomplete_marker || marker) { %>
<section class="card update-recovery-banner"> <section class="card update-recovery-banner">
@ -104,28 +77,28 @@
</section> </section>
<% } %> <% } %>
<section class="card" data-update-panel> <section class="card" data-update-panel data-update-target="core">
<details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>> <details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>>
<summary> <summary>
<span> <span>
<strong>Core</strong> <strong>Core</strong>
<span class="hint">Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %></span> <span class="hint" data-update-summary>Current <%= core?.current_version || "unknown" %> · Recommended <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %></span>
</span> </span>
<span class="badge <%= badgeClass(core) %>"><%= badgeText(core) %></span> <span class="badge <%= badgeClass(core) %>" data-update-badge><%= badgeText(core) %></span>
</summary> </summary>
<div class="lumi-expandable-body update-detail-grid"> <div class="lumi-expandable-body update-detail-grid">
<% if (core) { %> <% if (core) { %>
<div class="update-meta-grid"> <div class="update-meta-grid">
<div><span>Current</span><strong><%= core.current_version %></strong></div> <div><span>Current</span><strong data-update-field="current_version"><%= core.current_version %></strong></div>
<div><span>Safe target</span><strong><%= core.safe_target_version || "None" %></strong></div> <div><span>Recommended version</span><strong data-update-field="safe_target_version"><%= core.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= core.latest_available_version %></strong></div> <div><span>Latest found</span><strong data-update-field="latest_available_version"><%= core.latest_available_version %></strong></div>
<div><span>Source branch</span><strong><%= core.source_branch %></strong></div> <div><span>Update source</span><strong data-update-field="source_branch"><%= core.source_branch %></strong></div>
<div><span>Size change</span><strong><%= core.size_delta_label %></strong></div> <div><span>Size change</span><strong><%= core.size_delta_label %></strong></div>
<div><span>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></div> <div><span>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></div>
</div> </div>
<p><%= core.version_description %></p> <p data-update-description><%= core.version_description %></p>
<% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %> <% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %> <% if (core.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>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.</p></div><% } %>
<% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %> <% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %> <% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %> <% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %>
@ -144,32 +117,20 @@
<div class="inline-actions update-action-row"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/core/check" data-update-action> <form method="post" action="/admin/updates/core/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check core</button> <button class="button subtle" type="submit" data-update-check-button>Check for updates</button>
</form> </form>
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= core.unversioned_update ? "Apply unversioned core update" : "Apply core update" %>" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>"> <form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= core.unversioned_update ? "Apply unversioned core update" : "Apply core update" %>" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>><%= applyLabel(core) %></button> <button class="button" type="submit" data-update-apply-button <%= core.blocked || !core.update_available ? "disabled" : "" %>><%= core.unversioned_update ? "Review and update" : "Update core" %></button>
</form> </form>
<% if (core.snapshot.available) { %> <% if (core.snapshot.available) { %>
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert core snapshot" data-confirm-text="Revert only the previous core version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert core"> <form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Restore previous core backup" data-confirm-text="Restore only the previous core backup. Major-version restore is blocked unless metadata marks it safe." data-confirm-label="Restore core">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= core.snapshot.latest_snapshot_id %>" /> <input type="hidden" name="snapshot_id" value="<%= core.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button> <button class="button danger" type="submit">Restore previous version</button>
</form> </form>
<% } %> <% } %>
</div> </div>
<details class="inline-details">
<summary>Show advanced ZIP update options</summary>
<div class="callout">ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload core ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</details>
<% } %> <% } %>
</div> </div>
</details> </details>
@ -193,18 +154,18 @@
<summary> <summary>
<span> <span>
<strong><%= plugin.name %></strong> <strong><%= plugin.name %></strong>
<span class="hint"><%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span> <span class="hint" data-update-summary><%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
</span> </span>
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span> <span class="badge <%= badgeClass(plugin) %>" data-update-badge><%= badgeText(plugin) %></span>
</summary> </summary>
<div class="lumi-expandable-body update-detail-grid"> <div class="lumi-expandable-body update-detail-grid">
<div class="update-meta-grid"> <div class="update-meta-grid">
<div><span>Current</span><strong><%= plugin.current_version %></strong></div> <div><span>Current</span><strong data-update-field="current_version"><%= plugin.current_version %></strong></div>
<div><span>Safe target</span><strong><%= plugin.safe_target_version || "None" %></strong></div> <div><span>Recommended version</span><strong data-update-field="safe_target_version"><%= plugin.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= plugin.latest_available_version %></strong></div> <div><span>Latest found</span><strong data-update-field="latest_available_version"><%= plugin.latest_available_version %></strong></div>
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div> <div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
</div> </div>
<p><%= plugin.version_description %></p> <p data-update-description><%= plugin.version_description %></p>
<% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %> <% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %>
<% if (plugin.id === "lumi_ai" && tools.length) { %> <% if (plugin.id === "lumi_ai" && tools.length) { %>
<% const toolSummary = plugin.tools_summary || {}; %> <% const toolSummary = plugin.tools_summary || {}; %>
@ -252,7 +213,7 @@
</details> </details>
<% } %> <% } %>
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %> <% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %> <% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>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.</p></div><% } %>
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %> <% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %> <% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
<h3>Changelog to target</h3> <h3>Changelog to target</h3>
@ -268,39 +229,67 @@
<div class="inline-actions update-action-row"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check plugin</button> <button class="button subtle" type="submit" data-update-check-button>Check for updates</button>
</form> </form>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>"> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= plugin.installed === false ? "Install from repo" : applyLabel(plugin) %></button> <button class="button" type="submit" data-update-apply-button <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= plugin.installed === false ? "Install from repo" : "Update plugin" %></button>
</form> </form>
<% if (plugin.snapshot.available) { %> <% if (plugin.snapshot.available) { %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin"> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Restore previous plugin backup" data-confirm-text="Restore only the previous plugin backup. Major-version restore is blocked unless metadata marks it safe." data-confirm-label="Restore plugin">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= plugin.snapshot.latest_snapshot_id %>" /> <input type="hidden" name="snapshot_id" value="<%= plugin.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button> <button class="button danger" type="submit">Restore previous version</button>
</form> </form>
<% } %> <% } %>
<% if (plugin.installed !== false) { %> <% if (plugin.installed !== false) { %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin"> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
<button class="button danger" type="submit">Disable for recovery</button> <button class="button danger" type="submit">Disable temporarily</button>
</form> </form>
<% } %> <% } %>
</div> </div>
<details class="inline-details"> </div>
<summary>Show advanced plugin ZIP options</summary> </details>
<div class="callout">Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div> <% }) %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/zip" enctype="multipart/form-data" class="form-grid"> </div>
</details>
</section>
<section class="card">
<details class="lumi-expandable-settings">
<summary>
<span>
<strong>Manual ZIP updates</strong>
<span class="hint">Advanced fallback for recovery, offline installs, or when repository updates fail.</span>
</span>
<span class="badge warning">Advanced</span>
</summary>
<div class="lumi-expandable-body">
<p class="hint">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.</p>
<div class="grid">
<div class="card">
<h3>Core ZIP</h3>
<p class="hint">Use a full core ZIP or a patch ZIP containing files relative to the Lumi install root.</p>
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload core ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP notes say rollback is safe</span></label>
</form>
</div>
<div class="card">
<h3>Plugin ZIP</h3>
<p class="hint">Use when plugin repository metadata is unavailable. Plugin ZIPs must include a valid <code>plugin.json</code>.</p>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row"> <div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required /> <input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin ZIP</button> <button type="submit" class="button">Upload plugin ZIP</button>
</div> </div>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form> </form>
</details>
</div> </div>
</details> </div>
<% }) %>
</div> </div>
</details> </details>
</section> </section>
@ -334,7 +323,7 @@
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td> <td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td> <td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
<td><%= snap.update_method || "snapshot" %></td> <td><%= snap.update_method || "snapshot" %></td>
<td><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %></td> <td><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Previous-version restore allowed" %></td>
<td><%= new Date(snap.createdAt).toLocaleString() %></td> <td><%= new Date(snap.createdAt).toLocaleString() %></td>
</tr> </tr>
<% }) %> <% }) %>
@ -343,4 +332,101 @@
</div> </div>
<% } %> <% } %>
</section> </section>
<script>
(() => {
const root = document.querySelector("[data-update-notices]");
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
const showNotice = (message, tone = "info") => {
if (!root) return;
const item = document.createElement("div");
item.className = `update-notice ${tone}`;
item.setAttribute("role", tone === "danger" ? "alert" : "status");
const text = document.createElement("span");
text.textContent = message || "Update status changed.";
const close = document.createElement("button");
close.type = "button";
close.className = "icon-button";
close.setAttribute("aria-label", "Dismiss notification");
close.textContent = "×";
close.addEventListener("click", () => item.remove());
item.append(text, close);
root.append(item);
if (!["danger", "warning"].includes(tone)) {
window.setTimeout(() => item.remove(), 6500);
}
};
const setButtonLoading = (button, loading) => {
if (!button) return;
if (loading) {
button.dataset.originalText = button.textContent;
button.textContent = "Checking...";
button.disabled = true;
button.setAttribute("aria-busy", "true");
} else {
button.textContent = button.dataset.originalText || "Check for updates";
button.disabled = false;
button.setAttribute("aria-busy", "false");
}
};
const setText = (row, selector, value) => {
const target = row.querySelector(selector);
if (target) target.textContent = value || "None";
};
const updateRow = (row, item) => {
if (!row || !item) return;
const summary = row.querySelector("[data-update-summary]");
if (summary) {
const current = item.installed === false ? "Not installed" : item.current_version;
summary.textContent = `${current || "unknown"} -> ${item.safe_target_version || "none"} · ${item.source_branch || "main"}`;
}
const badge = row.querySelector("[data-update-badge]");
if (badge) {
badge.className = `badge ${badgeClass(item)}`;
badge.textContent = badgeText(item);
}
setText(row, "[data-update-field='current_version']", item.current_version);
setText(row, "[data-update-field='safe_target_version']", item.safe_target_version);
setText(row, "[data-update-field='latest_available_version']", item.latest_available_version);
setText(row, "[data-update-field='source_branch']", item.source_branch);
setText(row, "[data-update-description]", item.version_description);
const apply = row.querySelector("[data-update-apply-button]");
if (apply) apply.disabled = Boolean(item.blocked || !item.update_available);
};
document.querySelectorAll("form[data-update-action]").forEach((form) => {
if (!form.action.endsWith("/check")) return;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = event.submitter || form.querySelector("[data-update-check-button]");
const row = form.closest(".plugin-update-row, [data-update-target='core']");
setButtonLoading(button, true);
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
credentials: "same-origin"
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.ok === false) {
throw new Error(payload.error || "The update check failed.");
}
const item = payload.plugin || payload.status?.core;
updateRow(row, item);
const tone = item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
showNotice(payload.message || (item?.update_available ? "Updates are available." : "No updates found."), tone);
} catch (error) {
showNotice(error.message || "The update check failed. Try again or use manual ZIP recovery if repository checks keep failing.", "danger");
} finally {
setButtonLoading(button, false);
}
});
});
})();
</script>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -19,6 +19,11 @@
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" /> <img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
<% } else if (homepageHero.embed_url) { %> <% } else if (homepageHero.embed_url) { %>
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe> <iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
<% } else if (homepageHero.render_error) { %>
<div class="homepage-hero-media homepage-hero-error" role="status">
<strong>Hero unavailable</strong>
<span><%= homepageHero.render_error %></span>
</div>
<% } %> <% } %>
</section> </section>
<% } %> <% } %>