improve update and homepage hero ux
This commit is contained in:
parent
1c329bd551
commit
f99dbed310
41
TODO.md
41
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.
|
||||
|
||||
@ -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 = `<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>`;
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -90,11 +90,11 @@
|
||||
]
|
||||
}) %>
|
||||
</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", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Update from git" },
|
||||
{ id: "idle", text: "Update from repository" },
|
||||
{ id: "loading", text: "Updating", spinner: true },
|
||||
{ id: "success", text: "Updated" }
|
||||
]
|
||||
|
||||
@ -32,9 +32,9 @@
|
||||
<input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" />
|
||||
</div>
|
||||
<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)" />
|
||||
<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 class="field">
|
||||
<label>Update branch</label>
|
||||
@ -68,15 +68,15 @@
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"",
|
||||
attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from repository\" data-confirm-text=\"Download the recommended update from the configured repository, create a backup, apply it, and restart Lumi if the update succeeds.\" data-confirm-label=\"Update from repository\"",
|
||||
states: [
|
||||
{ id: "idle", text: "Update from git" },
|
||||
{ id: "idle", text: "Update from repository" },
|
||||
{ id: "loading", text: "Updating", spinner: true },
|
||||
{ id: "success", text: "Updated" }
|
||||
]
|
||||
}) %>
|
||||
</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 class="field full">
|
||||
|
||||
@ -12,12 +12,12 @@
|
||||
const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success";
|
||||
const toolBadgeText = (item) => item?.blocked ? "Blocked" : item?.installed === false ? "Available" : item?.update_available ? "Update available" : "Current";
|
||||
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
|
||||
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
|
||||
const applyLabel = (item) => item?.unversioned_update ? "Review and update" : "Use recommended version";
|
||||
const applyConfirmText = (item, label) => {
|
||||
const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`;
|
||||
const base = `${label}: create a backup, prepare recovery, apply ${item?.safe_target_version || "the selected update"}, and verify before finishing.`;
|
||||
if (!item?.unversioned_update) return base;
|
||||
const warnings = (item.warnings || []).join(" ");
|
||||
return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`;
|
||||
return `${base} Warning: this update is to or from an unversioned install or update, so Lumi cannot fully verify version order, changelog range, or restore safety. ${warnings}`;
|
||||
};
|
||||
%>
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Maintenance",
|
||||
pageTitle: "Updates",
|
||||
description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback."
|
||||
description: "Repository-first core and plugin updates with backups, recovery markers, restore actions, and advanced ZIP fallback."
|
||||
}) %>
|
||||
<% if (updateStatusError) { %>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<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>
|
||||
<div class="update-notice-stack" data-update-notices aria-live="polite"></div>
|
||||
|
||||
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||
<section class="card update-recovery-banner">
|
||||
@ -104,28 +77,28 @@
|
||||
</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" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<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 class="badge <%= badgeClass(core) %>"><%= badgeText(core) %></span>
|
||||
<span class="badge <%= badgeClass(core) %>" data-update-badge><%= badgeText(core) %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body update-detail-grid">
|
||||
<% if (core) { %>
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Current</span><strong><%= core.current_version %></strong></div>
|
||||
<div><span>Safe target</span><strong><%= core.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Latest</span><strong><%= core.latest_available_version %></strong></div>
|
||||
<div><span>Source branch</span><strong><%= core.source_branch %></strong></div>
|
||||
<div><span>Current</span><strong data-update-field="current_version"><%= core.current_version %></strong></div>
|
||||
<div><span>Recommended version</span><strong data-update-field="safe_target_version"><%= core.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Latest found</span><strong data-update-field="latest_available_version"><%= core.latest_available_version %></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>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></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.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.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><% } %>
|
||||
@ -144,32 +117,20 @@
|
||||
<div class="inline-actions update-action-row">
|
||||
<form method="post" action="/admin/updates/core/check" data-update-action>
|
||||
<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 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 %>" />
|
||||
<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>
|
||||
<% 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="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>
|
||||
<% } %>
|
||||
</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>
|
||||
</details>
|
||||
@ -193,18 +154,18 @@
|
||||
<summary>
|
||||
<span>
|
||||
<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 class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
|
||||
<span class="badge <%= badgeClass(plugin) %>" data-update-badge><%= badgeText(plugin) %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body update-detail-grid">
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Current</span><strong><%= plugin.current_version %></strong></div>
|
||||
<div><span>Safe target</span><strong><%= plugin.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Latest</span><strong><%= plugin.latest_available_version %></strong></div>
|
||||
<div><span>Current</span><strong data-update-field="current_version"><%= plugin.current_version %></strong></div>
|
||||
<div><span>Recommended version</span><strong data-update-field="safe_target_version"><%= plugin.safe_target_version || "None" %></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>
|
||||
<p><%= plugin.version_description %></p>
|
||||
<p data-update-description><%= plugin.version_description %></p>
|
||||
<% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %>
|
||||
<% if (plugin.id === "lumi_ai" && tools.length) { %>
|
||||
<% const toolSummary = plugin.tools_summary || {}; %>
|
||||
@ -252,7 +213,7 @@
|
||||
</details>
|
||||
<% } %>
|
||||
<% 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.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
|
||||
<h3>Changelog to target</h3>
|
||||
@ -268,36 +229,25 @@
|
||||
<div class="inline-actions update-action-row">
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
|
||||
<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 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 %>" />
|
||||
<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>
|
||||
<% 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="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>
|
||||
<% } %>
|
||||
<% 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">
|
||||
<button class="button danger" type="submit">Disable for recovery</button>
|
||||
<button class="button danger" type="submit">Disable temporarily</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
<details class="inline-details">
|
||||
<summary>Show advanced plugin ZIP options</summary>
|
||||
<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 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>
|
||||
<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>
|
||||
</details>
|
||||
<% }) %>
|
||||
@ -305,6 +255,45 @@
|
||||
</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">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload plugin ZIP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Live Progress</h2>
|
||||
<div class="update-progress-log" data-update-progress-log>
|
||||
@ -334,7 +323,7 @@
|
||||
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
|
||||
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></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>
|
||||
</tr>
|
||||
<% }) %>
|
||||
@ -343,4 +332,101 @@
|
||||
</div>
|
||||
<% } %>
|
||||
</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") %>
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
|
||||
<% } else if (homepageHero.embed_url) { %>
|
||||
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
<% } 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>
|
||||
<% } %>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user