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

Maintenance

- + <%- 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" } + ] + }) %>
-
- + + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>
- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Restart bot" }, + { id: "loading", text: "Restarting", spinner: true }, + { id: "success", text: "Restarting" } + ] + }) %>
diff --git a/src/web/views/admin-plugins.ejs b/src/web/views/admin-plugins.ejs index 78bade3..1f20bea 100644 --- a/src/web/views/admin-plugins.ejs +++ b/src/web/views/admin-plugins.ejs @@ -28,12 +28,28 @@
- + <%- 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" } + ] + }) %>
- + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + states: [ + { id: "idle", text: "Update" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>
-
+
@@ -49,7 +65,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload plugin" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>
@@ -60,7 +83,14 @@ - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Install plugin" }, + { id: "loading", text: "Installing", spinner: true }, + { id: "success", text: "Installed" } + ] + }) %>
@@ -78,7 +108,14 @@ - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Create plugin" }, + { id: "loading", text: "Creating", spinner: true }, + { id: "success", text: "Created" } + ] + }) %>
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index 834fa4c..afb097c 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -41,23 +41,34 @@
- - - + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Save settings" }, + { id: "loading", text: "Saving", spinner: true }, + { id: "success", text: "Saved" } + ] + }) %> + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"", + states: [ + { id: "idle", text: "Check for updates" }, + { id: "loading", text: "Checking", spinner: true }, + { id: "success", text: "Checked" } + ] + }) %> + <%- include("partials/state-button", { + type: "submit", + classes: "subtle", + attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %>

Git update checks use the configured remote and branch.

@@ -144,7 +155,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Save settings" }, + { id: "loading", text: "Saving", spinner: true }, + { id: "success", text: "Saved" } + ] + }) %>
diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 83894e5..8d0ce8e 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -12,12 +12,27 @@

Git updates

Check or pull updates from the remote and branch configured in Settings.

-
- -
-
- -
+
+ <%- 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" } + ] + }) %> +
+
+ <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Update from git" }, + { id: "loading", text: "Updating", spinner: true }, + { id: "success", text: "Updated" } + ] + }) %> +
@@ -26,7 +41,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload bot update" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>
@@ -44,7 +66,14 @@
- + <%- include("partials/state-button", { + type: "submit", + states: [ + { id: "idle", text: "Upload plugin update" }, + { id: "loading", text: "Uploading", spinner: true }, + { id: "success", text: "Uploaded" } + ] + }) %>