Lumi/src/web/public/content-library.js
2026-07-20 22:45:14 +02:00

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);
}
})();