ui: refine homepage builder and update controls
This commit is contained in:
parent
fdb7aafc69
commit
64da8ae103
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,3 +16,6 @@ npm-debug.log
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
taskfile.txt
|
||||
codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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") %>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user