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-*.md
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
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>`
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)

View File

@ -1 +0,0 @@

View File

@ -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);
}
});

View File

@ -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 = `
<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 = () => {
@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
})();

View File

@ -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;

View File

@ -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

View File

@ -74,13 +74,36 @@
<h2>Maintenance</h2>
<div class="button-group">
<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 method="post" action="/admin/update" class="inline-form">
<button type="submit" class="button">Update from git</button>
<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>
<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>
</div>
</section>

View File

@ -28,12 +28,28 @@
<td>
<form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form">
<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 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 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>
</form>
</td>
@ -49,7 +65,14 @@
<form method="post" action="/admin/plugins/upload" 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</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload plugin" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div>
</form>
</section>
@ -60,7 +83,14 @@
<label>Repository URL</label>
<input name="url" placeholder="https://gitea.example.com/org/plugin.git" />
</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>
</section>
<section class="card">
@ -78,7 +108,14 @@
<label>Description</label>
<input name="description" />
</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>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -41,23 +41,34 @@
</div>
<div class="field full">
<div class="inline-actions">
<button type="submit" class="button">Save settings</button>
<button
type="submit"
class="button subtle"
formaction="/admin/check-update"
formmethod="post"
>
Check for updates
</button>
<button
type="submit"
class="button subtle"
formaction="/admin/update"
formmethod="post"
>
Update from git
</button>
<%- 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" }
]
}) %>
</div>
<p class="hint">Git update checks use the configured remote and branch.</p>
</div>
@ -144,7 +155,14 @@
</details>
</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>
</section>
<section class="card">

View File

@ -13,10 +13,25 @@
<p>Check or pull updates from the remote and branch configured in Settings.</p>
<div class="inline-actions">
<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 method="post" action="/admin/update" class="inline-form">
<button type="submit" class="button">Update from git</button>
<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>
</section>
@ -26,7 +41,14 @@
<form method="post" action="/admin/updates/bot" 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 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 class="field full">
<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">
<div class="field full input-action-row">
<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>
</form>
</section>