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-*.json
|
||||||
security-audit-*.md
|
security-audit-*.md
|
||||||
taskfile.txt
|
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
|
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)
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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") %>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -13,10 +13,25 @@
|
|||||||
<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", {
|
||||||
|
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>
|
||||||
</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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user