ui: refine homepage builder and update controls

This commit is contained in:
Franz Rolfsvaag 2026-06-16 09:19:27 +02:00
parent fdb7aafc69
commit 64da8ae103
11 changed files with 459 additions and 80 deletions

3
.gitignore vendored
View File

@ -16,3 +16,6 @@ npm-debug.log
security-audit-*.json security-audit-*.json
security-audit-*.md security-audit-*.md
taskfile.txt taskfile.txt
codex-guidelines
Twitch.png
twitch-credentials-lumi.png

View File

@ -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 shows a top Save changes bar, warns before accidental navigation, and clears
markers only after successful saves. 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 `<details>` Expandable settings rows use `data-lumi-expandable-settings` on a `<details>`
container. Preview text can be wired with `data-placeholder-preview="#field-id"`; container. Preview text can be wired with `data-placeholder-preview="#field-id"`;
known placeholders such as `{gifter_username}`, `{item_name}`, 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 JavaScript is unavailable, or unsaved settings are present, navigation falls back
to normal browser behavior. 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 ## Themes
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High 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 Admins configure homepage external link buttons from Admin > Settings with the
Homepage content builder. It writes the existing `homepage_link_buttons` JSON Homepage content builder. It writes the existing `homepage_link_buttons` JSON
setting behind the scenes. Each entry may include `enabled`, `label`, setting behind the scenes. Each entry may include `enabled`, `label`,
`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`, `description`, `url`, `icon_mode`, `icon_url`, `fetched_favicon_url`,
`admin`), and `sort_order`. Links open in a new tab with `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. `rel="noopener noreferrer"` and are filtered server-side by permission.
Admins configure priority-based hero entries with the same builder; it writes Admins configure priority-based hero entries with the same builder; it writes
the existing `homepage_hero_entries` JSON setting behind the scenes. The the existing `homepage_hero_entries` JSON setting behind the scenes. The
homepage renders the first enabled, available entry the current user can access. homepage renders the first enabled, available entry the current user can access.
Hero entries support type, priority/order, permission, source/embed/image URLs, Hero entries support type, priority/order, permission, source/embed/image URLs,
video IDs, availability mode, autoplay mode metadata, and duration fields. Slow video IDs, availability mode, mutually exclusive autoplay modes, duration
external availability checks are intentionally avoided; entries fail closed if fields, fallback behavior, and a live card preview. The builder shows
required local configuration is missing. 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 ## 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 filters the loaded entries client-side; changing range, severity, or limit
reloads the same `/admin/logs` route with query parameters. 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 ## Visual references
- [Home, desktop](screenshots/lumi-home-desktop.png) - [Home, desktop](screenshots/lumi-home-desktop.png)

View File

@ -1 +0,0 @@

View File

@ -537,10 +537,12 @@
const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]"); const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]");
const destructiveStates = new WeakMap(); const destructiveStates = new WeakMap();
let activeDestructive = null; let activeDestructive = null;
let activeCallbackConfirm = null;
const destructiveAction = (form) => { const destructiveAction = (form, submitter = null) => {
try { 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 { } catch {
return ""; return "";
} }
@ -558,10 +560,10 @@
return { title: "Confirm action", label: "Confirm" }; return { title: "Confirm action", label: "Confirm" };
}; };
const isDestructiveForm = (form) => { const isDestructiveForm = (form, submitter = null) => {
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
return String(form.method || "get").toLowerCase() === "post" && return String(form.method || "get").toLowerCase() === "post" &&
destructivePattern.test(destructiveAction(form)); destructivePattern.test(destructiveAction(form, submitter));
}; };
const resetDestructive = (form) => { const resetDestructive = (form) => {
@ -579,6 +581,41 @@
if (form.dataset.syntheticConfirmation === "true") form.remove(); 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) => { const submitDestructive = (form, submitter, token) => {
let tokenField = form.querySelector('input[name="confirmation_token"]'); let tokenField = form.querySelector('input[name="confirmation_token"]');
if (!tokenField) { if (!tokenField) {
@ -594,14 +631,14 @@
form.requestSubmit(submitter?.form === form ? submitter : undefined); 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 startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
const state = destructiveStates.get(form) || {}; const state = destructiveStates.get(form) || {};
const update = () => { const update = () => {
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000)); const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
button.disabled = remaining > 0; 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) { if (!remaining && state.timer) {
window.clearInterval(state.timer); window.clearInterval(state.timer);
state.timer = null; state.timer = null;
@ -618,20 +655,21 @@
const issueDestructiveConfirmation = async (form, submitter) => { const issueDestructiveConfirmation = async (form, submitter) => {
if (destructiveStates.has(form)) return; if (destructiveStates.has(form)) return;
const action = destructiveAction(form); const action = destructiveAction(form, submitter);
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null }; const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
destructiveStates.set(form, state); destructiveStates.set(form, state);
const copy = actionCopy(action); 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"); const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
let confirmButton; let confirmButton;
if (mode === "modal" && destructiveModal && destructiveConfirm) { if (mode === "modal" && destructiveModal && destructiveConfirm) {
if (activeDestructive?.form) resetDestructive(activeDestructive.form); if (activeDestructive?.form) resetDestructive(activeDestructive.form);
activeDestructive = { form }; activeDestructive = { form };
destructiveTitle.textContent = form.dataset.confirmTitle || copy.title; destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title;
destructiveDescription.textContent = message; destructiveDescription.textContent = message;
destructiveConfirm.disabled = true; destructiveConfirm.disabled = true;
destructiveConfirm.classList.add("danger");
destructiveConfirm.textContent = "Preparing..."; destructiveConfirm.textContent = "Preparing...";
destructiveModal.classList.add("is-open"); destructiveModal.classList.add("is-open");
destructiveModal.setAttribute("aria-hidden", "false"); destructiveModal.setAttribute("aria-hidden", "false");
@ -683,7 +721,7 @@
document.addEventListener("submit", (event) => { document.addEventListener("submit", (event) => {
const form = event.target; 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); const state = destructiveStates.get(form);
if (state?.confirmed) { if (state?.confirmed) {
state.confirmed = false; state.confirmed = false;
@ -716,16 +754,21 @@
document.querySelectorAll("[data-destructive-cancel]").forEach((button) => { document.querySelectorAll("[data-destructive-cancel]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (activeDestructive?.form) resetDestructive(activeDestructive.form); if (activeDestructive?.form) resetDestructive(activeDestructive.form);
else resetCallbackConfirm(false);
}); });
}); });
destructiveModal?.addEventListener("click", (event) => { destructiveModal?.addEventListener("click", (event) => {
if (event.target === destructiveModal && activeDestructive?.form) { if (event.target === destructiveModal && activeDestructive?.form) {
resetDestructive(activeDestructive.form); resetDestructive(activeDestructive.form);
} else if (event.target === destructiveModal) {
resetCallbackConfirm(false);
} }
}); });
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && activeDestructive?.form) { if (event.key === "Escape" && activeDestructive?.form) {
resetDestructive(activeDestructive.form); resetDestructive(activeDestructive.form);
} else if (event.key === "Escape") {
resetCallbackConfirm(false);
} }
}); });

View File

@ -3,7 +3,28 @@
if (!builders.length) return; if (!builders.length) return;
const permissions = ["public", "user", "mod", "admin"]; 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) => { const parseRows = (source) => {
try { try {
@ -14,9 +35,10 @@
} }
}; };
const field = (label, input) => { const field = (label, input, options = {}) => {
const wrapper = document.createElement("label"); const wrapper = document.createElement("label");
wrapper.className = "homepage-builder-field"; wrapper.className = "homepage-builder-field";
if (options.relevance) wrapper.dataset.relevance = options.relevance;
const span = document.createElement("span"); const span = document.createElement("span");
span.textContent = label; span.textContent = label;
wrapper.append(span, input); wrapper.append(span, input);
@ -41,10 +63,11 @@
const selectInput = (value, values) => { const selectInput = (value, values) => {
const select = document.createElement("select"); const select = document.createElement("select");
values.forEach((item) => { values.forEach((item) => {
const [id, label] = Array.isArray(item) ? item : [item, item];
const option = document.createElement("option"); const option = document.createElement("option");
option.value = item; option.value = id;
option.textContent = item; option.textContent = label;
option.selected = item === value; option.selected = id === value;
select.append(option); select.append(option);
}); });
return select; return select;
@ -62,14 +85,16 @@
label: "", label: "",
description: "", description: "",
url: "", url: "",
icon_mode: "favicon",
icon_url: "", icon_url: "",
fetched_favicon_url: "",
permission: "public", permission: "public",
sort_order: 0 sort_order: 0
}); });
const heroDefaults = () => ({ const heroDefaults = () => ({
enabled: true, enabled: true,
type: "image", type: "static_image",
title: "", title: "",
description: "", description: "",
priority: 0, priority: 0,
@ -80,9 +105,23 @@
video_id: "", video_id: "",
availability_mode: "always", availability_mode: "always",
autoplay_mode: "off", 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) => { builders.forEach((builder) => {
const kind = builder.dataset.homepageBuilder; const kind = builder.dataset.homepageBuilder;
const source = builder.querySelector(".homepage-json-source"); const source = builder.querySelector(".homepage-json-source");
@ -99,7 +138,9 @@
label: row.querySelector("[data-field='label']").value.trim(), label: row.querySelector("[data-field='label']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(),
url: row.querySelector("[data-field='url']").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(), 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, permission: row.querySelector("[data-field='permission']").value,
sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index
}; };
@ -109,23 +150,103 @@
type: row.querySelector("[data-field='type']").value, type: row.querySelector("[data-field='type']").value,
title: row.querySelector("[data-field='title']").value.trim(), title: row.querySelector("[data-field='title']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(), description: row.querySelector("[data-field='description']").value.trim(),
priority: Number(row.querySelector("[data-field='priority']").value) || 0, priority: Number(row.querySelector("[data-field='priority']").value) || index,
permission: row.querySelector("[data-field='permission']").value, permission: row.querySelector("[data-field='permission']").value,
source_url: row.querySelector("[data-field='source_url']").value.trim(), source_url: row.querySelector("[data-field='source_url']").value.trim(),
image_url: row.querySelector("[data-field='image_url']").value.trim(), image_url: row.querySelector("[data-field='image_url']").value.trim(),
embed_url: row.querySelector("[data-field='embed_url']").value.trim(), embed_url: row.querySelector("[data-field='embed_url']").value.trim(),
video_id: row.querySelector("[data-field='video_id']").value.trim(), video_id: row.querySelector("[data-field='video_id']").value.trim(),
availability_mode: row.querySelector("[data-field='availability_mode']").value.trim() || "always", availability_mode: row.querySelector("[data-field='availability_mode']").value,
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value.trim() || "off", autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0 duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
fallback_behavior: row.querySelector("[data-field='fallback_behavior']").value
}; };
}); });
rows = next;
source.value = JSON.stringify(next, null, 2); 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; 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 = `
<span class="homepage-preview-icon">${iconUrl ? `<img src="${escapeHtml(iconUrl)}" alt="">` : escapeHtml(firstLetter(url))}</span>
<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description)}</small></span>
`;
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 = `
<span class="eyebrow">${escapeHtml(heroTypeLabel(type))}</span>
<strong>${escapeHtml(title)}</strong>
<small>${escapeHtml(description)}</small>
`;
});
};
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 = () => { const render = () => {
@ -148,26 +269,48 @@
addField(row, "Label", textInput(item.label, "Commands"), "label"); addField(row, "Label", textInput(item.label, "Commands"), "label");
addField(row, "Description", textInput(item.description, "Open command list"), "description"); addField(row, "Description", textInput(item.description, "Open command list"), "description");
addField(row, "URL", textInput(item.url, "/commands"), "url"); 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, "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 { } 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, "Title", textInput(item.title, "Featured stream"), "title");
addField(row, "Description", textInput(item.description, "What's happening now"), "description"); 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, "Permission", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Source URL", textInput(item.source_url, "https://..."), "source_url"); 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"); 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"); 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"); addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" });
addField(row, "Availability", textInput(item.availability_mode || "always"), "availability_mode"); addField(row, "Availability mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" });
addField(row, "Autoplay", textInput(item.autoplay_mode || "off"), "autoplay_mode"); addField(row, "Autoplay mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
addField(row, "Duration seconds", numberInput(item.duration_seconds, 0), "duration_seconds"); 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"); const actions = document.createElement("div");
actions.className = "homepage-builder-actions"; 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"); const duplicate = document.createElement("button");
duplicate.type = "button"; duplicate.type = "button";
duplicate.className = "button subtle"; duplicate.className = "button subtle";
@ -180,15 +323,28 @@
remove.type = "button"; remove.type = "button";
remove.className = "button danger"; remove.className = "button danger";
remove.textContent = "Remove"; 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); rows.splice(index, 1);
render(); render();
}); });
actions.append(duplicate, remove); actions.append(up, down, duplicate, remove);
row.append(actions); row.append(actions);
row.addEventListener("input", sync); row.addEventListener("input", sync);
row.addEventListener("change", sync); row.addEventListener("change", () => {
updateLinkRelevance(row);
updateHeroRelevance(row);
sync();
});
list.append(row); list.append(row);
updateLinkRelevance(row);
updateHeroRelevance(row);
}); });
sync(); sync();
}; };
@ -200,4 +356,13 @@
render(); render();
}); });
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
})(); })();

View File

@ -689,6 +689,28 @@ input[type="color"] {
font-size: 0.85rem; 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-metric-grid,
.dashboard-chart-grid { .dashboard-chart-grid {
display: grid; display: grid;

View File

@ -1562,12 +1562,18 @@ function homepageLinksForUser(user) {
.map((item, index) => { .map((item, index) => {
const url = safeExternalUrl(item.url); const url = safeExternalUrl(item.url);
if (!url) return null; 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 { return {
id: String(item.id || `link-${index}`), id: String(item.id || `link-${index}`),
label: String(item.label || item.description || "External link").slice(0, 80), label: String(item.label || item.description || "External link").slice(0, 80),
description: String(item.description || item.label || "Open link").slice(0, 160), description: String(item.description || item.label || "Open link").slice(0, 160),
url, url,
icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url), icon_url: iconUrl,
fallback_icon: fallbackIconForUrl(url), fallback_icon: fallbackIconForUrl(url),
permission: item.permission || "public", permission: item.permission || "public",
sort_order: Number(item.sort_order) || index sort_order: Number(item.sort_order) || index

View File

@ -74,13 +74,36 @@
<h2>Maintenance</h2> <h2>Maintenance</h2>
<div class="button-group"> <div class="button-group">
<form method="post" action="/admin/check-update" class="inline-form"> <form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button> <%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Check for updates" },
{ id: "loading", text: "Checking", spinner: true },
{ id: "success", text: "Checked" }
]
}) %>
</form> </form>
<form method="post" action="/admin/update" class="inline-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">
<button type="submit" class="button">Update from git</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Update from git" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</form> </form>
<form method="post" action="/admin/restart" class="inline-form"> <form method="post" action="/admin/restart" class="inline-form">
<button type="submit" class="button subtle">Restart bot</button> <%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Restart bot" },
{ id: "loading", text: "Restarting", spinner: true },
{ id: "success", text: "Restarting" }
]
}) %>
</form> </form>
</div> </div>
</section> </section>

View File

@ -28,12 +28,28 @@
<td> <td>
<form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form"> <form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form">
<input type="hidden" name="enabled" value="<%= plugin.enabled ? 'false' : 'true' %>" /> <input type="hidden" name="enabled" value="<%= plugin.enabled ? 'false' : 'true' %>" />
<button type="submit" class="button subtle"><%= plugin.enabled ? "Disable" : "Enable" %></button> <%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: plugin.enabled ? "Disable" : "Enable" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</form> </form>
<form method="post" action="/admin/plugins/<%= plugin.id %>/update" class="inline-form"> <form method="post" action="/admin/plugins/<%= plugin.id %>/update" class="inline-form">
<button type="submit" class="button subtle">Update</button> <%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Update" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</form> </form>
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi."> <form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi." data-confirm-label="Uninstall plugin">
<button type="submit" class="button danger">Uninstall</button> <button type="submit" class="button danger">Uninstall</button>
</form> </form>
</td> </td>
@ -49,7 +65,14 @@
<form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row"> <div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required /> <input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload plugin" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div> </div>
</form> </form>
</section> </section>
@ -60,7 +83,14 @@
<label>Repository URL</label> <label>Repository URL</label>
<input name="url" placeholder="https://gitea.example.com/org/plugin.git" /> <input name="url" placeholder="https://gitea.example.com/org/plugin.git" />
</div> </div>
<button type="submit" class="button">Install plugin</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Install plugin" },
{ id: "loading", text: "Installing", spinner: true },
{ id: "success", text: "Installed" }
]
}) %>
</form> </form>
</section> </section>
<section class="card"> <section class="card">
@ -78,7 +108,14 @@
<label>Description</label> <label>Description</label>
<input name="description" /> <input name="description" />
</div> </div>
<button type="submit" class="button">Create plugin</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Create plugin" },
{ id: "loading", text: "Creating", spinner: true },
{ id: "success", text: "Created" }
]
}) %>
</form> </form>
</section> </section>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -41,23 +41,34 @@
</div> </div>
<div class="field full"> <div class="field full">
<div class="inline-actions"> <div class="inline-actions">
<button type="submit" class="button">Save settings</button> <%- include("partials/state-button", {
<button type: "submit",
type="submit" states: [
class="button subtle" { id: "idle", text: "Save settings" },
formaction="/admin/check-update" { id: "loading", text: "Saving", spinner: true },
formmethod="post" { id: "success", text: "Saved" }
> ]
Check for updates }) %>
</button> <%- include("partials/state-button", {
<button type: "submit",
type="submit" classes: "subtle",
class="button subtle" attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"",
formaction="/admin/update" states: [
formmethod="post" { id: "idle", text: "Check for updates" },
> { id: "loading", text: "Checking", spinner: true },
Update from git { id: "success", text: "Checked" }
</button> ]
}) %>
<%- 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" }
]
}) %>
</div> </div>
<p class="hint">Git update checks use the configured remote and branch.</p> <p class="hint">Git update checks use the configured remote and branch.</p>
</div> </div>
@ -144,7 +155,14 @@
</details> </details>
</div> </div>
<button type="submit" class="button">Save settings</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Save settings" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</form> </form>
</section> </section>
<section class="card"> <section class="card">

View File

@ -12,12 +12,27 @@
<h2>Git updates</h2> <h2>Git updates</h2>
<p>Check or pull updates from the remote and branch configured in Settings.</p> <p>Check or pull updates from the remote and branch configured in Settings.</p>
<div class="inline-actions"> <div class="inline-actions">
<form method="post" action="/admin/check-update" class="inline-form"> <form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button> <%- include("partials/state-button", {
</form> type: "submit",
<form method="post" action="/admin/update" class="inline-form"> classes: "subtle",
<button type="submit" class="button">Update from git</button> states: [
</form> { id: "idle", text: "Check for updates" },
{ id: "loading", text: "Checking", spinner: true },
{ id: "success", text: "Checked" }
]
}) %>
</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">
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Update from git" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</form>
</div> </div>
</section> </section>
@ -26,7 +41,14 @@
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row"> <div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required /> <input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload bot update</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload bot update" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div> </div>
<div class="field full"> <div class="field full">
<label>Patch mode (apply only files in ZIP, skip full package verification)</label> <label>Patch mode (apply only files in ZIP, skip full package verification)</label>
@ -44,7 +66,14 @@
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row"> <div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required /> <input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin update</button> <%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload plugin update" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div> </div>
</form> </form>
</section> </section>