(() => { if (window.LumiContentLibrary?.init) { window.LumiContentLibrary.init(document); return; } const initializedLibraries = new WeakSet(); const initializedUploads = new WeakSet(); let previewReturnFocus = null; function init(root = document) { root.querySelectorAll?.("[data-content-library]").forEach(initLibrary); root.querySelectorAll?.("[data-resource-upload-form]").forEach(initUploadForm); } function initLibrary(library) { if (initializedLibraries.has(library)) return; initializedLibraries.add(library); const update = () => applyFilters(library); library.querySelector("[data-resource-search]")?.addEventListener("input", update); library.querySelector("[data-resource-category-filter]")?.addEventListener("change", update); library.querySelector("[data-resource-access-filter]")?.addEventListener("change", update); applyFilters(library); } function initUploadForm(form) { if (initializedUploads.has(form)) return; initializedUploads.add(form); const input = form.querySelector("[data-resource-file-input]"); const dropzone = form.querySelector("[data-resource-dropzone]"); if (!input || !dropzone) return; input.addEventListener("change", () => renderSelectedFiles(form)); dropzone.addEventListener("keydown", (event) => { if (!["Enter", " "].includes(event.key)) return; event.preventDefault(); input.click(); }); ["dragenter", "dragover"].forEach((type) => { dropzone.addEventListener(type, (event) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; dropzone.classList.add("is-dragging"); }); }); ["dragleave", "dragend", "drop"].forEach((type) => { dropzone.addEventListener(type, () => dropzone.classList.remove("is-dragging")); }); dropzone.addEventListener("drop", (event) => { event.preventDefault(); if (!event.dataTransfer?.files?.length) return; try { const transfer = new DataTransfer(); Array.from(event.dataTransfer.files).forEach((file) => transfer.items.add(file)); input.files = transfer.files; } catch { input.files = event.dataTransfer.files; } renderSelectedFiles(form); }); form.addEventListener("submit", (event) => { event.preventDefault(); uploadResources(form); }); } function renderSelectedFiles(form) { const input = form.querySelector("[data-resource-file-input]"); const list = form.querySelector("[data-resource-selected-files]"); if (!input || !list) return; list.replaceChildren(); const files = Array.from(input.files || []); list.hidden = files.length === 0; files.forEach((file) => { const row = document.createElement("div"); row.className = "resource-selected-file"; const name = document.createElement("span"); name.textContent = file.name; name.title = file.name; const size = document.createElement("small"); size.textContent = formatBytes(file.size); row.append(name, size); list.append(row); }); } function uploadResources(form) { const input = form.querySelector("[data-resource-file-input]"); const files = Array.from(input?.files || []); if (!files.length) { setStatus(form, "Choose at least one file to upload.", "error"); return; } const button = form.querySelector("[data-resource-upload-submit]"); const progress = form.querySelector("[data-resource-upload-progress]"); const bar = form.querySelector("[data-resource-upload-progress-bar]"); const text = form.querySelector("[data-resource-upload-progress-text]"); button.disabled = true; progress.hidden = false; setStatus(form, "", ""); updateUploadProgress(progress, bar, text, 0, "Preparing upload…"); const request = new XMLHttpRequest(); request.open((form.getAttribute("method") || "POST").toUpperCase(), form.getAttribute("action")); request.setRequestHeader("Accept", "application/json"); request.setRequestHeader("X-Requested-With", "XMLHttpRequest"); request.upload.addEventListener("progress", (event) => { if (!event.lengthComputable) { if (text) text.textContent = "Uploading…"; return; } const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))); updateUploadProgress(progress, bar, text, percent, `Uploading… ${percent}%`); }); request.addEventListener("load", () => { let payload = {}; try { payload = JSON.parse(request.responseText || "{}"); } catch {} if (request.status < 200 || request.status >= 300 || !payload.ok) { finishUpload(form, button, progress, payload.error || `Upload failed (${request.status || "network error"}).`, false); return; } const library = form.closest("[data-content-library]"); payload.resources?.forEach((resource) => addOrUpdateResource(library, resource)); updateStorage(library, payload.storage); form.reset(); renderSelectedFiles(form); finishUpload(form, button, progress, payload.message || "Resources uploaded.", true); applyFilters(library); }); request.addEventListener("error", () => { finishUpload(form, button, progress, "Upload failed because Lumi could not be reached.", false); }); request.addEventListener("abort", () => { finishUpload(form, button, progress, "Upload cancelled.", false); }); request.send(new FormData(form)); } function updateUploadProgress(progress, bar, text, percent, message) { progress?.querySelector("[role='progressbar']")?.setAttribute("aria-valuenow", String(percent)); if (bar) bar.style.width = `${percent}%`; if (text) text.textContent = message; } function finishUpload(form, button, progress, message, success) { button.disabled = false; if (success) { const bar = progress.querySelector("[data-resource-upload-progress-bar]"); const text = progress.querySelector("[data-resource-upload-progress-text]"); updateUploadProgress(progress, bar, text, 100, "Upload complete"); window.setTimeout(() => { progress.hidden = true; }, 900); } else { progress.hidden = true; } setStatus(form, message, success ? "success" : "error"); notify(message, success ? "info" : "danger"); } document.addEventListener("submit", async (event) => { const form = event.target.closest?.("[data-resource-action-form]"); if (!form) return; if (event.defaultPrevented) return; event.preventDefault(); const submitter = event.submitter; const button = submitter || form.querySelector("button[type='submit'], input[type='submit']"); const body = new FormData(form); if (body.has("confirmation_token")) { document.querySelector("[data-destructive-cancel]")?.click(); } if (button) button.disabled = true; try { const response = await fetch(effectiveAction(form, submitter), { method: effectiveMethod(form, submitter), body, headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" }, credentials: "same-origin" }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.ok) throw new Error(payload.error || "The resource update failed."); const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]"); if (payload.resource) addOrUpdateResource(library, payload.resource); if (payload.deleted_id) removeResource(library, payload.deleted_id); if (payload.storage) updateStorage(library, payload.storage); if (payload.settings) updateSettingsForm(library, payload.settings); setStatus(library, payload.message || "Saved.", "success"); notify(payload.message || "Saved.", "info"); applyFilters(library); } catch (error) { const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]"); setStatus(library, error.message || "The resource update failed.", "error"); notify(error.message || "The resource update failed.", "danger"); } finally { if (button && document.contains(button)) button.disabled = false; } }); document.addEventListener("click", (event) => { const previewButton = event.target.closest?.("[data-resource-preview]"); if (previewButton) { event.preventDefault(); openPreview(previewButton); return; } const closeButton = event.target.closest?.("[data-resource-preview-close]"); if (closeButton) { event.preventDefault(); closePreview(); return; } const copyButton = event.target.closest?.("[data-resource-copy-url]"); if (copyButton) { event.preventDefault(); const input = copyButton.closest("[data-resource-public-block]")?.querySelector("[data-resource-public-url]"); copyText(input?.value || "", copyButton); return; } const modal = event.target.closest?.("[data-resource-preview-modal]"); if (modal && event.target === modal) closePreview(); }); document.addEventListener("keydown", (event) => { const modal = document.querySelector("[data-resource-preview-modal].is-open"); if (!modal) return; if (event.key === "Escape") { event.preventDefault(); closePreview(); return; } if (event.key !== "Tab") return; const focusable = Array.from(modal.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' )).filter((item) => !item.hidden && item.getClientRects().length > 0); if (!focusable.length) { event.preventDefault(); modal.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); function addOrUpdateResource(library, resource) { if (!library || !resource) return; const existing = library.querySelector(`[data-resource-card][data-resource-id="${cssEscape(resource.id)}"]`); if (existing) { updateResourceCard(existing, resource); return existing; } const card = buildResourceCard(resource); library.querySelector("[data-resource-grid]")?.prepend(card); library.querySelector("[data-resource-empty]")?.setAttribute("hidden", ""); return card; } function updateResourceCard(card, resource) { card.dataset.resourceName = `${resource.display_name} ${resource.original_name} ${resource.mime}`.toLowerCase(); card.dataset.resourceCategory = resource.category; card.dataset.resourceAccess = resource.access_level; card.querySelector("[data-resource-title]").textContent = resource.display_name; const rename = card.querySelector('input[name="display_name"]'); if (rename) rename.value = resource.display_name; const badge = card.querySelector("[data-resource-access-badge]"); if (badge) { badge.textContent = resource.access_level === "exposed" ? "Exposed" : "Locked"; badge.classList.toggle("success", resource.access_level === "exposed"); } const block = card.querySelector("[data-resource-public-block]"); const publicInput = card.querySelector("[data-resource-public-url]"); if (block) block.hidden = !resource.public_url; if (publicInput) publicInput.value = resource.public_url || ""; const accessForm = card.querySelector("[data-resource-access-form]"); const accessButton = card.querySelector("[data-resource-access-button]"); if (accessForm) { accessForm.setAttribute("action", `/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`); if (resource.access_level === "exposed") { accessForm.dataset.confirmMode = "modal"; accessForm.dataset.confirmTitle = "Lock resource"; accessForm.dataset.confirmText = "Lock this resource and revoke its current read-only URL? Anything using that URL will stop working."; accessForm.dataset.confirmLabel = "Lock resource"; } else { delete accessForm.dataset.confirmMode; delete accessForm.dataset.confirmTitle; delete accessForm.dataset.confirmText; delete accessForm.dataset.confirmLabel; } } if (accessButton) accessButton.textContent = resource.access_level === "exposed" ? "Lock" : "Expose"; card.querySelectorAll("[data-resource-preview]").forEach((button) => applyPreviewData(button, resource)); } function removeResource(library, id) { library?.querySelector(`[data-resource-card][data-resource-id="${cssEscape(id)}"]`)?.remove(); } function buildResourceCard(resource) { const card = element("article", "resource-card"); card.dataset.resourceCard = ""; card.dataset.resourceId = resource.id; card.dataset.resourceName = `${resource.display_name} ${resource.original_name} ${resource.mime}`.toLowerCase(); card.dataset.resourceCategory = resource.category; card.dataset.resourceAccess = resource.access_level; const tile = element("button", "resource-preview-tile"); tile.type = "button"; tile.dataset.resourcePreview = ""; tile.setAttribute("aria-label", `Preview ${resource.display_name}`); applyPreviewData(tile, resource); if (resource.preview_kind === "image") { const image = document.createElement("img"); image.src = resource.admin_url; image.alt = ""; image.loading = "lazy"; tile.append(image); } else { const mark = element("span", "resource-type-mark", resource.extension.replace(".", "").toUpperCase()); mark.setAttribute("aria-hidden", "true"); tile.append(mark, element("span", "", "Preview")); } const body = element("div", "resource-card-body"); const heading = element("div", "resource-card-heading"); const headingText = document.createElement("div"); const title = element("h3", "", resource.display_name); title.dataset.resourceTitle = ""; const original = element("p", "", resource.original_name); original.title = resource.original_name; headingText.append(title, original); const badge = element("span", `badge${resource.access_level === "exposed" ? " success" : ""}`, resource.access_level === "exposed" ? "Exposed" : "Locked"); badge.dataset.resourceAccessBadge = ""; heading.append(headingText, badge); const metadata = element("dl", "resource-metadata"); metadata.append(metadataItem("Type", `${resource.category} · ${resource.extension}`), metadataItem("Size", resource.size_display)); const publicBlock = element("div", "resource-public-url"); publicBlock.dataset.resourcePublicBlock = ""; publicBlock.hidden = !resource.public_url; publicBlock.append(element("label", "", "Read-only URL")); const urlRow = element("div", "resource-url-row"); const urlInput = document.createElement("input"); urlInput.readOnly = true; urlInput.value = resource.public_url || ""; urlInput.dataset.resourcePublicUrl = ""; urlInput.setAttribute("aria-label", `Read-only URL for ${resource.display_name}`); const copy = button("Copy", "button subtle"); copy.dataset.resourceCopyUrl = ""; urlRow.append(urlInput, copy); publicBlock.append(urlRow); const actions = element("div", "resource-card-actions"); const preview = button("Preview", "button subtle"); preview.dataset.resourcePreview = ""; applyPreviewData(preview, resource); const download = element("a", "button subtle", "Download"); download.href = `${resource.admin_url}?download=1`; download.download = ""; const accessForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`); accessForm.dataset.resourceAccessForm = ""; if (resource.access_level === "exposed") { accessForm.dataset.confirmMode = "modal"; accessForm.dataset.confirmTitle = "Lock resource"; accessForm.dataset.confirmText = "Lock this resource and revoke its current read-only URL? Anything using that URL will stop working."; accessForm.dataset.confirmLabel = "Lock resource"; } const accessButton = button(resource.access_level === "exposed" ? "Lock" : "Expose", "button subtle", "submit"); accessButton.dataset.resourceAccessButton = ""; accessForm.append(accessButton); actions.append(preview, download, accessForm); const details = element("details", "resource-edit-details"); details.append(element("summary", "", "More actions")); const renameForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/rename`, "resource-rename-form"); const renameLabel = document.createElement("label"); renameLabel.append(element("span", "", "Display name")); const renameInput = document.createElement("input"); renameInput.name = "display_name"; renameInput.maxLength = 160; renameInput.required = true; renameInput.value = resource.display_name; renameLabel.append(renameInput); renameForm.append(renameLabel, button("Rename", "button subtle", "submit")); const deleteForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/delete`); deleteForm.dataset.confirmMode = "modal"; deleteForm.dataset.confirmTitle = "Delete resource"; deleteForm.dataset.confirmText = `Permanently delete '${resource.display_name}' from Lumi? Any overlay or feature using it will stop working.`; deleteForm.dataset.confirmLabel = "Delete resource"; deleteForm.append(button("Delete resource", "button danger", "submit")); details.append(renameForm, deleteForm); body.append(heading, metadata, publicBlock, actions, details); card.append(tile, body); return card; } function actionForm(action, className = "") { const form = element("form", className); form.method = "post"; form.action = action; form.dataset.resourceActionForm = ""; return form; } function metadataItem(label, value) { const wrapper = document.createElement("div"); wrapper.append(element("dt", "", label), element("dd", "", value)); return wrapper; } function button(text, className, type = "button") { const item = element("button", className, text); item.type = type; return item; } function element(tag, className = "", text = null) { const item = document.createElement(tag); if (className) item.className = className; if (text !== null) item.textContent = text; return item; } function applyPreviewData(button, resource) { button.dataset.resourceId = resource.id; button.dataset.resourceDisplayName = resource.display_name; button.dataset.resourceOriginalName = resource.original_name; button.dataset.resourceUrl = resource.admin_url; button.dataset.resourcePreviewKind = resource.preview_kind; button.dataset.resourceMime = resource.mime; button.dataset.resourceSize = resource.size_display; } function applyFilters(library) { if (!library) return; const search = (library.querySelector("[data-resource-search]")?.value || "").trim().toLowerCase(); const category = library.querySelector("[data-resource-category-filter]")?.value || ""; const access = library.querySelector("[data-resource-access-filter]")?.value || ""; const cards = Array.from(library.querySelectorAll("[data-resource-card]")); let visible = 0; cards.forEach((card) => { const matches = (!search || card.dataset.resourceName.includes(search)) && (!category || card.dataset.resourceCategory === category) && (!access || card.dataset.resourceAccess === access); card.hidden = !matches; if (matches) visible += 1; }); const empty = library.querySelector("[data-resource-empty]"); const filterEmpty = library.querySelector("[data-resource-filter-empty]"); if (empty) empty.hidden = cards.length > 0; if (filterEmpty) filterEmpty.hidden = cards.length === 0 || visible > 0; } async function openPreview(source) { const card = source.closest("[data-resource-card]"); const dataSource = source.dataset.resourceUrl ? source : card?.querySelector(".resource-preview-tile[data-resource-preview]"); if (!dataSource) return; const modal = document.querySelector("[data-resource-preview-modal]"); const stage = modal?.querySelector("[data-resource-preview-stage]"); const error = modal?.querySelector("[data-resource-preview-error]"); if (!modal || !stage || !error) return; stage.replaceChildren(); error.hidden = true; error.textContent = ""; modal.querySelector("[data-resource-preview-title]").textContent = dataSource.dataset.resourceDisplayName || "Resource preview"; modal.querySelector("[data-resource-preview-meta]").textContent = `${dataSource.dataset.resourceOriginalName || ""} · ${dataSource.dataset.resourceSize || ""}`; const download = modal.querySelector("[data-resource-preview-download]"); download.href = `${dataSource.dataset.resourceUrl}?download=1`; previewReturnFocus = source instanceof HTMLElement ? source : document.activeElement; modal.classList.add("is-open"); modal.setAttribute("aria-hidden", "false"); const kind = dataSource.dataset.resourcePreviewKind; const url = dataSource.dataset.resourceUrl; if (kind === "image") { const image = document.createElement("img"); image.src = url; image.alt = dataSource.dataset.resourceDisplayName || "Resource preview"; image.addEventListener("error", () => showPreviewError(error, "This browser could not decode the image format.")); stage.append(image); } else if (kind === "video") { const video = document.createElement("video"); video.controls = true; video.playsInline = true; video.preload = "metadata"; video.src = url; video.addEventListener("error", () => showPreviewError(error, "This browser could not decode the video. The original file is still stored and available for download or delivery.")); stage.append(video); } else if (kind === "audio") { const audio = document.createElement("audio"); audio.controls = true; audio.preload = "metadata"; audio.src = url; audio.addEventListener("error", () => showPreviewError(error, "This browser could not decode the audio. The original file is still stored and available for download or delivery.")); stage.append(audio); } else if (kind === "pdf") { const frame = document.createElement("iframe"); frame.src = url; frame.title = dataSource.dataset.resourceDisplayName || "PDF preview"; frame.setAttribute("sandbox", ""); stage.append(frame); } else if (kind === "text") { const pre = document.createElement("pre"); pre.textContent = "Loading preview…"; stage.append(pre); try { const response = await fetch(url, { headers: { Range: "bytes=0-131071" }, credentials: "same-origin" }); if (!response.ok && response.status !== 206) throw new Error("Preview unavailable."); const text = await response.text(); pre.textContent = text + (text.length >= 131072 ? "\n\n— Preview truncated —" : ""); } catch (fetchError) { pre.remove(); showPreviewError(error, fetchError.message || "Preview unavailable."); } } else if (kind === "font") { const family = `LumiResource_${String(dataSource.dataset.resourceId || "font").replace(/[^a-z0-9]/gi, "_")}`; const style = document.createElement("style"); style.dataset.resourceFontPreview = ""; style.textContent = `@font-face { font-family: "${family}"; src: url("${url.replace(/["\\]/g, "")}"); }`; const sample = element("div", "resource-font-preview", "Cozy Carnage · Lumi 0123456789"); sample.style.fontFamily = `"${family}", sans-serif`; stage.append(style, sample); } else { stage.append(element("div", "resource-preview-fallback", "This format does not have an in-browser preview. Use Download to inspect the original file.")); } window.setTimeout(() => modal.querySelector("[data-resource-preview-close]")?.focus(), 0); } function closePreview() { const modal = document.querySelector("[data-resource-preview-modal].is-open"); if (!modal) return; modal.querySelectorAll("video, audio").forEach((media) => { media.pause(); media.removeAttribute("src"); media.load(); }); modal.querySelector("[data-resource-preview-stage]")?.replaceChildren(); modal.classList.remove("is-open"); modal.setAttribute("aria-hidden", "true"); const returnTarget = previewReturnFocus; previewReturnFocus = null; if (returnTarget instanceof HTMLElement && document.contains(returnTarget)) { window.setTimeout(() => returnTarget.focus(), 0); } } function showPreviewError(target, message) { target.textContent = message; target.hidden = false; } function updateStorage(library, storage) { if (!library || !storage) return; setText(library, "[data-storage-available]", storage.effective_available_bytes === null ? "Unknown" : formatBytes(storage.effective_available_bytes)); setText(library, "[data-storage-used]", formatBytes(storage.used_bytes)); setText(library, "[data-storage-count]", String(storage.file_count)); setText(library, "[data-storage-disk-free]", storage.disk_free_bytes === null ? "Unavailable" : formatBytes(storage.disk_free_bytes)); setText(library, "[data-storage-limit]", storage.quota_enabled ? formatBytes(storage.limit_bytes) : "Unlimited"); setText(library, "[data-storage-max-file]", formatBytes(storage.max_file_bytes)); setText(library, "[data-storage-available-note]", storage.quota_enabled ? "Limited by the smaller of remaining quota and usable disk space." : "Based on disk space after Lumi's safety reserve."); const meter = library.querySelector("[data-storage-meter]"); if (meter) meter.style.width = `${Math.max(0, Math.min(100, Number(storage.usage_percent) || 0))}%`; const progress = meter?.closest("[role='progressbar']"); progress?.setAttribute("aria-valuenow", String(storage.usage_percent || 0)); } function updateSettingsForm(library, settings) { if (!library || !settings) return; const form = library.querySelector("[data-resource-settings-form]"); if (!form) return; for (const [name, value] of Object.entries(settings)) { const field = form.elements.namedItem(name); if (field) field.value = value; } } function setText(root, selector, value) { const target = root?.querySelector(selector); if (target) target.textContent = value; } function setStatus(root, message, tone = "") { const target = root?.matches?.("[data-resource-status]") ? root : root?.querySelector?.("[data-resource-status]") || document.querySelector("[data-resource-status]"); if (!target) return; target.textContent = message || ""; target.classList.toggle("is-error", tone === "error"); target.classList.toggle("is-success", tone === "success"); } function notify(message, tone) { window.LumiInteractions?.showEventNotice?.({ message }, tone); } async function copyText(value, buttonTarget) { if (!value) return; try { await navigator.clipboard.writeText(value); } catch { const helper = document.createElement("textarea"); helper.value = value; helper.style.position = "fixed"; helper.style.opacity = "0"; document.body.append(helper); helper.select(); document.execCommand("copy"); helper.remove(); } const original = buttonTarget.textContent; buttonTarget.textContent = "Copied"; window.setTimeout(() => { if (document.contains(buttonTarget)) buttonTarget.textContent = original; }, 1200); } function effectiveAction(form, submitter) { return window.LumiForms?.action?.(form, submitter) || submitter?.formAction || form.getAttribute("action") || window.location.href; } function effectiveMethod(form, submitter) { return (window.LumiForms?.method?.(form, submitter) || submitter?.formMethod || form.getAttribute("method") || "POST").toUpperCase(); } function cssEscape(value) { if (window.CSS?.escape) return window.CSS.escape(String(value)); return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function formatBytes(value) { let bytes = Math.max(0, Number(value) || 0); if (bytes < 1024) return `${Math.round(bytes)} B`; const units = ["KB", "MB", "GB", "TB"]; let unit = "B"; for (const next of units) { bytes /= 1024; unit = next; if (bytes < 1024) break; } return `${bytes >= 10 ? bytes.toFixed(1) : bytes.toFixed(2)} ${unit}`; } window.LumiContentLibrary = Object.freeze({ init }); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => init(document), { once: true }); } else { init(document); } })();