655 lines
29 KiB
JavaScript
655 lines
29 KiB
JavaScript
(() => {
|
|
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);
|
|
}
|
|
})();
|