diff --git a/.gitignore b/.gitignore
index 07581c2..823709b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,6 @@ npm-debug.log
security-audit-*.json
security-audit-*.md
taskfile.txt
+codex-guidelines
+Twitch.png
+twitch-credentials-lumi.png
diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md
index b9b432b..d6452ea 100644
--- a/docs/lumi-ui.md
+++ b/docs/lumi-ui.md
@@ -52,6 +52,19 @@ tracks original values, marks changed fields with theme-aware unsaved styling,
shows a top Save changes bar, warns before accidental navigation, and clears
markers only after successful saves.
+Buttons should use `partials/state-button.ejs` for submit, loading, success, or
+error states. Single-state and multi-state buttons share the same Lumi button
+tokens. Hidden states stay measurable with `data-state-hidden`, so the button
+width is based on the widest state and state changes do not shift surrounding
+layout. Use `.input-action-row` for desktop file/input + action pairs such as
+ZIP updates and navigation icon uploads; the row stacks on mobile.
+
+Destructive POST forms should provide context through `data-confirm-title`,
+`data-confirm-text`, and `data-confirm-label`. Dynamic JavaScript-only
+destructive actions can call `window.LumiConfirm.destructive({ title, text,
+label })` to reuse the same modal. The helper keeps vague default confirmation
+copy out of normal admin flows and returns focus after cancel/confirm.
+
Expandable settings rows use `data-lumi-expandable-settings` on a ``
container. Preview text can be wired with `data-placeholder-preview="#field-id"`;
known placeholders such as `{gifter_username}`, `{item_name}`,
@@ -63,6 +76,10 @@ Soft navigation progressively enhances same-origin links by replacing
JavaScript is unavailable, or unsaved settings are present, navigation falls back
to normal browser behavior.
+Sidebar navigation sections behave as an accordion. Opening one `.nav-section`
+closes the other expanded sections while preserving the active page highlight
+and `aria-expanded` state.
+
## Themes
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
@@ -142,17 +159,21 @@ fallbacks.
Admins configure homepage external link buttons from Admin > Settings with the
Homepage content builder. It writes the existing `homepage_link_buttons` JSON
setting behind the scenes. Each entry may include `enabled`, `label`,
-`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`,
-`admin`), and `sort_order`. Links open in a new tab with
+`description`, `url`, `icon_mode`, `icon_url`, `fetched_favicon_url`,
+`permission` (`public`, `user`, `mod`, `admin`), and `sort_order`. Entries can
+be added, duplicated, moved up/down, removed with contextual confirmation,
+enabled/disabled, and previewed in place. Links open in a new tab with
`rel="noopener noreferrer"` and are filtered server-side by permission.
Admins configure priority-based hero entries with the same builder; it writes
the existing `homepage_hero_entries` JSON setting behind the scenes. The
homepage renders the first enabled, available entry the current user can access.
Hero entries support type, priority/order, permission, source/embed/image URLs,
-video IDs, availability mode, autoplay mode metadata, and duration fields. Slow
-external availability checks are intentionally avoided; entries fail closed if
-required local configuration is missing.
+video IDs, availability mode, mutually exclusive autoplay modes, duration
+fields, fallback behavior, and a live card preview. The builder shows
+video-only controls only for stream/video types and image/embed/source fields
+only when they apply. Slow external availability checks are intentionally
+avoided; entries fail closed if required local configuration is missing.
## Admin Dashboard And Logs
@@ -166,6 +187,19 @@ responsive filter bar with search, reset, refresh, and download actions. Search
filters the loaded entries client-side; changing range, severity, or limit
reloads the same `/admin/logs` route with query parameters.
+## Updates And Local-Only Files
+
+Admin update and ZIP upload controls use the same state-button and
+input-action-row patterns as other Lumi actions. Git update actions have
+contextual confirmation copy because they can restart the process. ZIP update
+forms still submit to the existing `/admin/updates/bot` and
+`/admin/updates/plugin` routes and keep the existing snapshot behavior.
+
+The repository ignores local-only coordination and credential artifacts such as
+`codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin
+runtime data stays excluded from source control; runtime folders are recreated
+by the plugin data-directory initializer.
+
## Visual references
- [Home, desktop](screenshots/lumi-home-desktop.png)
diff --git a/plugins/lumi_ai/data/runtime/.gitkeep b/plugins/lumi_ai/data/runtime/.gitkeep
deleted file mode 100644
index 8b13789..0000000
--- a/plugins/lumi_ai/data/runtime/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 3c1a7f9..afc5342 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -537,10 +537,12 @@
const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]");
const destructiveStates = new WeakMap();
let activeDestructive = null;
+ let activeCallbackConfirm = null;
- const destructiveAction = (form) => {
+ const destructiveAction = (form, submitter = null) => {
try {
- return new URL(form.action, window.location.origin).pathname;
+ const action = submitter?.formAction || form.action;
+ return new URL(action, window.location.origin).pathname;
} catch {
return "";
}
@@ -558,10 +560,10 @@
return { title: "Confirm action", label: "Confirm" };
};
- const isDestructiveForm = (form) => {
+ const isDestructiveForm = (form, submitter = null) => {
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
return String(form.method || "get").toLowerCase() === "post" &&
- destructivePattern.test(destructiveAction(form));
+ destructivePattern.test(destructiveAction(form, submitter));
};
const resetDestructive = (form) => {
@@ -579,6 +581,41 @@
if (form.dataset.syntheticConfirmation === "true") form.remove();
};
+ const resetCallbackConfirm = (result = false) => {
+ const active = activeCallbackConfirm;
+ if (!active) return;
+ activeCallbackConfirm = null;
+ destructiveModal?.classList.remove("is-open");
+ destructiveModal?.setAttribute("aria-hidden", "true");
+ destructiveConfirm?.removeEventListener("click", active.onConfirm);
+ active.resolve(result);
+ active.returnFocus?.focus?.();
+ };
+
+ window.LumiConfirm = {
+ destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) {
+ if (!destructiveModal || !destructiveConfirm) {
+ return Promise.resolve(window.confirm(text));
+ }
+ if (activeDestructive?.form) resetDestructive(activeDestructive.form);
+ if (activeCallbackConfirm) resetCallbackConfirm(false);
+ return new Promise((resolve) => {
+ const returnFocus = document.activeElement;
+ destructiveTitle.textContent = title;
+ destructiveDescription.textContent = text;
+ destructiveConfirm.disabled = false;
+ destructiveConfirm.textContent = label;
+ destructiveConfirm.classList.toggle("danger", danger);
+ destructiveModal.classList.add("is-open");
+ destructiveModal.setAttribute("aria-hidden", "false");
+ const onConfirm = () => resetCallbackConfirm(true);
+ activeCallbackConfirm = { resolve, returnFocus, onConfirm };
+ destructiveConfirm.addEventListener("click", onConfirm);
+ destructiveConfirm.focus();
+ });
+ }
+ };
+
const submitDestructive = (form, submitter, token) => {
let tokenField = form.querySelector('input[name="confirmation_token"]');
if (!tokenField) {
@@ -594,14 +631,14 @@
form.requestSubmit(submitter?.form === form ? submitter : undefined);
};
- const confirmLabel = (form) => form.dataset.confirmLabel || actionCopy(destructiveAction(form)).label;
+ const confirmLabel = (form, submitter = null) => form.dataset.confirmLabel || submitter?.dataset?.confirmLabel || actionCopy(destructiveAction(form, submitter)).label;
const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
const state = destructiveStates.get(form) || {};
const update = () => {
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
button.disabled = remaining > 0;
- button.textContent = remaining > 0 ? `${confirmLabel(form)} in ${remaining}` : confirmLabel(form);
+ button.textContent = remaining > 0 ? `${confirmLabel(form, submitter)} in ${remaining}` : confirmLabel(form, submitter);
if (!remaining && state.timer) {
window.clearInterval(state.timer);
state.timer = null;
@@ -618,20 +655,21 @@
const issueDestructiveConfirmation = async (form, submitter) => {
if (destructiveStates.has(form)) return;
- const action = destructiveAction(form);
+ const action = destructiveAction(form, submitter);
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
destructiveStates.set(form, state);
const copy = actionCopy(action);
- const message = form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
+ const message = submitter?.dataset?.confirmText || form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
let confirmButton;
if (mode === "modal" && destructiveModal && destructiveConfirm) {
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
activeDestructive = { form };
- destructiveTitle.textContent = form.dataset.confirmTitle || copy.title;
+ destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title;
destructiveDescription.textContent = message;
destructiveConfirm.disabled = true;
+ destructiveConfirm.classList.add("danger");
destructiveConfirm.textContent = "Preparing...";
destructiveModal.classList.add("is-open");
destructiveModal.setAttribute("aria-hidden", "false");
@@ -683,7 +721,7 @@
document.addEventListener("submit", (event) => {
const form = event.target;
- if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form)) return;
+ if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form, event.submitter)) return;
const state = destructiveStates.get(form);
if (state?.confirmed) {
state.confirmed = false;
@@ -716,16 +754,21 @@
document.querySelectorAll("[data-destructive-cancel]").forEach((button) => {
button.addEventListener("click", () => {
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
+ else resetCallbackConfirm(false);
});
});
destructiveModal?.addEventListener("click", (event) => {
if (event.target === destructiveModal && activeDestructive?.form) {
resetDestructive(activeDestructive.form);
+ } else if (event.target === destructiveModal) {
+ resetCallbackConfirm(false);
}
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && activeDestructive?.form) {
resetDestructive(activeDestructive.form);
+ } else if (event.key === "Escape") {
+ resetCallbackConfirm(false);
}
});
diff --git a/src/web/public/homepage-builder.js b/src/web/public/homepage-builder.js
index 777ad40..beb3afe 100644
--- a/src/web/public/homepage-builder.js
+++ b/src/web/public/homepage-builder.js
@@ -3,7 +3,28 @@
if (!builders.length) return;
const permissions = ["public", "user", "mod", "admin"];
- const heroTypes = ["image", "video", "embed"];
+ const heroTypes = [
+ ["static_image", "Static image"],
+ ["custom_embed", "Custom embed"],
+ ["custom_link", "Custom link"],
+ ["youtube_video", "YouTube video"],
+ ["youtube_channel", "YouTube channel"],
+ ["twitch_stream", "Twitch stream"],
+ ["discord_server_overview", "Discord server overview"],
+ ["none", "Fallback message"]
+ ];
+ const availabilityModes = [
+ ["always", "Always available"],
+ ["live_only", "Only while live"],
+ ["scheduled", "Scheduled/manual"]
+ ];
+ const autoplayModes = [
+ ["off", "No autoplay"],
+ ["muted", "Autoplay muted"],
+ ["sound", "Autoplay with sound"]
+ ];
+
+ const heroTypeLabel = (value) => heroTypes.find(([id]) => id === value)?.[1] || value || "Hero";
const parseRows = (source) => {
try {
@@ -14,9 +35,10 @@
}
};
- const field = (label, input) => {
+ const field = (label, input, options = {}) => {
const wrapper = document.createElement("label");
wrapper.className = "homepage-builder-field";
+ if (options.relevance) wrapper.dataset.relevance = options.relevance;
const span = document.createElement("span");
span.textContent = label;
wrapper.append(span, input);
@@ -41,10 +63,11 @@
const selectInput = (value, values) => {
const select = document.createElement("select");
values.forEach((item) => {
+ const [id, label] = Array.isArray(item) ? item : [item, item];
const option = document.createElement("option");
- option.value = item;
- option.textContent = item;
- option.selected = item === value;
+ option.value = id;
+ option.textContent = label;
+ option.selected = id === value;
select.append(option);
});
return select;
@@ -62,14 +85,16 @@
label: "",
description: "",
url: "",
+ icon_mode: "favicon",
icon_url: "",
+ fetched_favicon_url: "",
permission: "public",
sort_order: 0
});
const heroDefaults = () => ({
enabled: true,
- type: "image",
+ type: "static_image",
title: "",
description: "",
priority: 0,
@@ -80,9 +105,23 @@
video_id: "",
availability_mode: "always",
autoplay_mode: "off",
- duration_seconds: 0
+ duration_seconds: 0,
+ fallback_behavior: "message"
});
+ const firstLetter = (value) => {
+ try {
+ return new URL(value || "").hostname.replace(/^www\./, "").slice(0, 1).toUpperCase() || "L";
+ } catch {
+ return "L";
+ }
+ };
+
+ const confirmation = async (options) => {
+ if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options);
+ return window.confirm(options.text);
+ };
+
builders.forEach((builder) => {
const kind = builder.dataset.homepageBuilder;
const source = builder.querySelector(".homepage-json-source");
@@ -99,7 +138,9 @@
label: row.querySelector("[data-field='label']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(),
url: row.querySelector("[data-field='url']").value.trim(),
+ icon_mode: row.querySelector("[data-field='icon_mode']").value,
icon_url: row.querySelector("[data-field='icon_url']").value.trim(),
+ fetched_favicon_url: row.querySelector("[data-field='fetched_favicon_url']").value.trim(),
permission: row.querySelector("[data-field='permission']").value,
sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index
};
@@ -109,23 +150,103 @@
type: row.querySelector("[data-field='type']").value,
title: row.querySelector("[data-field='title']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(),
- priority: Number(row.querySelector("[data-field='priority']").value) || 0,
+ 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(),
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(),
- availability_mode: row.querySelector("[data-field='availability_mode']").value.trim() || "always",
- autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value.trim() || "off",
- duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0
+ 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,
+ fallback_behavior: row.querySelector("[data-field='fallback_behavior']").value
};
});
+ rows = next;
source.value = JSON.stringify(next, null, 2);
+ source.dispatchEvent(new Event("input", { bubbles: true }));
+ renderPreviews();
};
- const addField = (row, labelText, element, name) => {
+ const addField = (row, labelText, element, name, options = {}) => {
element.dataset.field = name;
- row.append(field(labelText, element));
+ row.append(field(labelText, element, options));
+ return element;
+ };
+
+ const updateHeroRelevance = (row) => {
+ if (kind !== "heroes") return;
+ const type = row.querySelector("[data-field='type']")?.value || "static_image";
+ const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type);
+ const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
+ const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
+ const image = type === "static_image";
+ const fallback = type === "none";
+ row.querySelectorAll("[data-relevance]").forEach((item) => {
+ const relevance = item.dataset.relevance;
+ const visible =
+ relevance === "video" ? videoLike :
+ relevance === "embed" ? embedded :
+ relevance === "source" ? source :
+ relevance === "image" ? image :
+ relevance === "fallback" ? fallback :
+ true;
+ item.hidden = !visible;
+ });
+ };
+
+ const updateLinkRelevance = (row) => {
+ if (kind !== "links") return;
+ const mode = row.querySelector("[data-field='icon_mode']")?.value || "favicon";
+ row.querySelectorAll("[data-relevance]").forEach((item) => {
+ const relevance = item.dataset.relevance;
+ item.hidden = !(
+ (relevance === "manual-icon" && mode === "manual") ||
+ (relevance === "fetched-icon" && mode === "favicon")
+ );
+ });
+ };
+
+ const renderPreviews = () => {
+ list.querySelectorAll("[data-homepage-row]").forEach((row) => {
+ const preview = row.querySelector("[data-homepage-preview]");
+ if (!preview) return;
+ if (kind === "links") {
+ const label = row.querySelector("[data-field='label']").value.trim() || "Homepage link";
+ const description = row.querySelector("[data-field='description']").value.trim() || "Open external link";
+ const url = row.querySelector("[data-field='url']").value.trim();
+ const iconMode = row.querySelector("[data-field='icon_mode']").value;
+ const iconUrl =
+ iconMode === "manual" ? row.querySelector("[data-field='icon_url']").value.trim() :
+ iconMode === "favicon" ? row.querySelector("[data-field='fetched_favicon_url']").value.trim() :
+ "";
+ preview.innerHTML = `
+ ${iconUrl ? `
` : escapeHtml(firstLetter(url))}
+ ${escapeHtml(label)}${escapeHtml(description)}
+ `;
+ return;
+ }
+ const type = row.querySelector("[data-field='type']").value;
+ const title = row.querySelector("[data-field='title']").value.trim() || heroTypeLabel(type);
+ const description = row.querySelector("[data-field='description']").value.trim() || "Hero preview";
+ preview.innerHTML = `
+ ${escapeHtml(heroTypeLabel(type))}
+ ${escapeHtml(title)}
+ ${escapeHtml(description)}
+ `;
+ });
+ };
+
+ const moveRow = (from, direction) => {
+ const to = from + direction;
+ if (to < 0 || to >= rows.length) return;
+ const [item] = rows.splice(from, 1);
+ rows.splice(to, 0, item);
+ rows.forEach((row, index) => {
+ if (kind === "links") row.sort_order = index;
+ else row.priority = index;
+ });
+ render();
};
const render = () => {
@@ -148,26 +269,48 @@
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");
- addField(row, "Icon URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_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, 0), "sort_order");
+ addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order");
+ iconMode.addEventListener("change", () => updateLinkRelevance(row));
} else {
- addField(row, "Type", selectInput(item.type || "image", heroTypes), "type");
+ 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", numberInput(item.priority, 0), "priority");
+ addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority");
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission");
- addField(row, "Source URL", textInput(item.source_url, "https://..."), "source_url");
- addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url");
- addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url");
- addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id");
- addField(row, "Availability", textInput(item.availability_mode || "always"), "availability_mode");
- addField(row, "Autoplay", textInput(item.autoplay_mode || "off"), "autoplay_mode");
- addField(row, "Duration seconds", numberInput(item.duration_seconds, 0), "duration_seconds");
+ 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" });
+ addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
+ typeSelect.addEventListener("change", () => updateHeroRelevance(row));
}
+ const preview = document.createElement("div");
+ preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero";
+ preview.dataset.homepagePreview = "";
+ row.append(preview);
+
const actions = document.createElement("div");
actions.className = "homepage-builder-actions";
+ const up = document.createElement("button");
+ up.type = "button";
+ up.className = "button subtle";
+ up.textContent = "Move up";
+ up.disabled = index === 0;
+ up.addEventListener("click", () => moveRow(index, -1));
+ const down = document.createElement("button");
+ down.type = "button";
+ down.className = "button subtle";
+ down.textContent = "Move down";
+ down.disabled = index === rows.length - 1;
+ down.addEventListener("click", () => moveRow(index, 1));
const duplicate = document.createElement("button");
duplicate.type = "button";
duplicate.className = "button subtle";
@@ -180,15 +323,28 @@
remove.type = "button";
remove.className = "button danger";
remove.textContent = "Remove";
- remove.addEventListener("click", () => {
+ remove.addEventListener("click", async () => {
+ const name = item.label || item.title || `${kind === "links" ? "link" : "hero"} ${index + 1}`;
+ const confirmed = await confirmation({
+ title: kind === "links" ? "Remove homepage link" : "Remove homepage hero",
+ text: `Remove ${name}? This only becomes permanent after you save settings.`,
+ label: "Remove"
+ });
+ if (!confirmed) return;
rows.splice(index, 1);
render();
});
- actions.append(duplicate, remove);
+ actions.append(up, down, duplicate, remove);
row.append(actions);
row.addEventListener("input", sync);
- row.addEventListener("change", sync);
+ row.addEventListener("change", () => {
+ updateLinkRelevance(row);
+ updateHeroRelevance(row);
+ sync();
+ });
list.append(row);
+ updateLinkRelevance(row);
+ updateHeroRelevance(row);
});
sync();
};
@@ -200,4 +356,13 @@
render();
});
+
+ function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
})();
diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css
index b14494e..6e0c3e2 100644
--- a/src/web/public/lumi-components.css
+++ b/src/web/public/lumi-components.css
@@ -689,6 +689,28 @@ input[type="color"] {
font-size: 0.85rem;
}
+.homepage-builder-field[hidden] {
+ display: none;
+}
+
+.homepage-builder-preview {
+ grid-column: 1 / -1;
+ margin: 0;
+}
+
+.homepage-builder-preview.hero {
+ min-height: 8rem;
+ display: grid;
+ align-content: center;
+ gap: var(--lumi-space-2);
+ padding: var(--lumi-space-4);
+}
+
+.homepage-builder-preview small,
+.homepage-builder-preview .hint {
+ color: var(--lumi-text-muted);
+}
+
.dashboard-metric-grid,
.dashboard-chart-grid {
display: grid;
diff --git a/src/web/server.js b/src/web/server.js
index 4317998..689c1c1 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -1562,12 +1562,18 @@ function homepageLinksForUser(user) {
.map((item, index) => {
const url = safeExternalUrl(item.url);
if (!url) return null;
+ const iconMode = String(item.icon_mode || "").trim();
+ const iconUrl =
+ iconMode === "manual" ? safeExternalUrl(item.icon_url) :
+ iconMode === "favicon" ? safeExternalUrl(item.fetched_favicon_url) :
+ iconMode === "letter" ? "" :
+ safeExternalUrl(item.icon_url || item.fetched_favicon_url);
return {
id: String(item.id || `link-${index}`),
label: String(item.label || item.description || "External link").slice(0, 80),
description: String(item.description || item.label || "Open link").slice(0, 160),
url,
- icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url),
+ icon_url: iconUrl,
fallback_icon: fallbackIconForUrl(url),
permission: item.permission || "public",
sort_order: Number(item.sort_order) || index
diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs
index 341a71a..65a42f6 100644
--- a/src/web/views/admin-dashboard.ejs
+++ b/src/web/views/admin-dashboard.ejs
@@ -74,13 +74,36 @@
Maintenance
-
diff --git a/src/web/views/admin-plugins.ejs b/src/web/views/admin-plugins.ejs
index 78bade3..1f20bea 100644
--- a/src/web/views/admin-plugins.ejs
+++ b/src/web/views/admin-plugins.ejs
@@ -28,12 +28,28 @@
-
|
@@ -49,7 +65,14 @@
@@ -60,7 +83,14 @@
-
+ <%- include("partials/state-button", {
+ type: "submit",
+ states: [
+ { id: "idle", text: "Install plugin" },
+ { id: "loading", text: "Installing", spinner: true },
+ { id: "success", text: "Installed" }
+ ]
+ }) %>
@@ -78,7 +108,14 @@
-
+ <%- include("partials/state-button", {
+ type: "submit",
+ states: [
+ { id: "idle", text: "Create plugin" },
+ { id: "loading", text: "Creating", spinner: true },
+ { id: "success", text: "Created" }
+ ]
+ }) %>
<%- include("partials/layout-bottom") %>
diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs
index 834fa4c..afb097c 100644
--- a/src/web/views/admin-settings.ejs
+++ b/src/web/views/admin-settings.ejs
@@ -41,23 +41,34 @@
-
-
-
+ <%- include("partials/state-button", {
+ type: "submit",
+ states: [
+ { id: "idle", text: "Save settings" },
+ { id: "loading", text: "Saving", spinner: true },
+ { id: "success", text: "Saved" }
+ ]
+ }) %>
+ <%- include("partials/state-button", {
+ type: "submit",
+ classes: "subtle",
+ attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"",
+ states: [
+ { id: "idle", text: "Check for updates" },
+ { id: "loading", text: "Checking", spinner: true },
+ { id: "success", text: "Checked" }
+ ]
+ }) %>
+ <%- 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\"",
+ states: [
+ { id: "idle", text: "Update from git" },
+ { id: "loading", text: "Updating", spinner: true },
+ { id: "success", text: "Updated" }
+ ]
+ }) %>
Git update checks use the configured remote and branch.
@@ -144,7 +155,14 @@
-
+ <%- include("partials/state-button", {
+ type: "submit",
+ states: [
+ { id: "idle", text: "Save settings" },
+ { id: "loading", text: "Saving", spinner: true },
+ { id: "success", text: "Saved" }
+ ]
+ }) %>
diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs
index 83894e5..8d0ce8e 100644
--- a/src/web/views/admin-updates.ejs
+++ b/src/web/views/admin-updates.ejs
@@ -12,12 +12,27 @@
Git updates
Check or pull updates from the remote and branch configured in Settings.
-
-
+
+
@@ -26,7 +41,14 @@