@astsiry/media-manager-ui
Version:
Private media manager UI for internal CDN/FTP use
1,093 lines (1,091 loc) • 45.8 kB
JavaScript
function isFolderNode(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export class MediaManager {
constructor({ containerId = "", basePath = "/", btnText = "Toogle media manager", btnId = "mediaToggle", API_URL = "http://192.168.0.121:8000", uiText, }) {
this.isReady = false;
this.searchTerm = "";
this.headerTitle = "";
// data-uiText=""
// ${this.uiText.}
this.uiText = {
headerTitle: "Media Manager",
defaultLoadingText: "Loading folder structure",
noFolder: "No folder",
noFile: "No file",
noFileSelected: "No file selected",
noFileFound: "No matching files found",
noFileInFolder: "No files in this folder",
dragAndDropText: "or drag & drop files here",
upload: "Upload",
searchPlaceholder: "Search files...",
loadingFolderStructure: "Loading folder structure",
newFolderTitle: "Enter new folder name:",
loadingFolderCreation: "Creating folder",
notifFolderCreateSuccess: "Folder created successfully",
notifFolderCreateError: "Failed to create folder",
failed: "Failed",
delete: "Delete",
deleteConfirmationText: "Are you sure ? <br>All instances that use this file will become unavailable.",
copied: "Copied!",
uploadSuccess: "Upload success",
uploadFailed: "Upload failed.",
deleteSuccess: "File deleted",
deleteFailed: "Failed to delete file.",
loadingUpload: "Uploading file",
loadingDelete: "Delleting file",
newFolder: "New Folder",
home: "Home",
imgCopied: "Image tag copied!",
imgCopyBtnText: "Image tag",
urlCopied: "URL copied!",
urlCopyBtnText: "Copy URL",
download: "Download",
confirmBtnText: "Yes",
cancelBtnText: "No",
promptBtnText: "OK",
cancelPropmtBtnText: "Cancel",
};
if (uiText) {
this.uiText = { ...this.uiText, ...uiText };
}
this.API_URL = API_URL;
this.openFolders = new Set();
this.container = document.getElementById(containerId);
this.basePath = basePath;
this.btnText = btnText;
this.btnId = btnId;
this.folderTree = {
"/": {},
};
this.currentPath = `/`;
this.selectedFile = null;
this._renderStructure();
this._bindEvents();
this._init();
}
async initCdnUrl() {
this.cdnUrl = await (await fetch(`${this.API_URL}/media/cdn_url`)).json();
if (this.basePath && this.basePath !== "/") {
this.cdnUrl = this.joinUrlParts(this.cdnUrl, this.basePath);
}
}
async _init() {
await this.initCdnUrl();
await this._fetchFolderTree();
// const rootData = await this._fetchFolderContent(this.currentPath);
// if (rootData) {
// const rootNode = this.folderTree["/"];
// rootNode.files = rootData.files;
// for (const f of rootData.folders) {
// rootNode[f.name] = {};
// if (f.hasContent) rootNode[f.name]._hasContent = true;
// }
// }
// console.log("Initial folder structure:", this.folderTree);
this._renderFolders();
this._renderThumbnails();
this._renderPreview();
this._renderImageViewerModal();
this.isReady = true;
this._toggleLoader(false);
}
getDefaultUIText() {
return this.uiText;
}
_renderStructure() {
if (!this.container)
return;
this.container.insertAdjacentHTML("afterbegin", `
<button class="media-btn" id="${this.btnId}">
<div class="loading-in-btn ant-spinner-wrapper">
<div class="ant-spinner-dot ant-dot-1"></div>
<div class="ant-spinner-dot ant-dot-2"></div>
<div class="ant-spinner-dot ant-dot-3"></div>
<div class="ant-spinner-dot ant-dot-4"></div>
</div>
${this.btnText}
</button>
`);
this.container.insertAdjacentHTML("beforeend", `
<div class="modal-backdrop" id="modalBackdrop"></div>
<div class="media-modal" id="mediaModal">
<div class="modal-header" data-uiText="headerTitle">
<h3><i class="bi bi-images"></i> ${this.uiText.headerTitle}</h3>
<span class="modal-close" id="modalClose">×</span>
</div>
<div class="modal-body">
<div id="media-loader" class="hide-loader">
<div class="ant-spinner-wrapper">
<div class="ant-spinner-dot ant-dot-1"></div>
<div class="ant-spinner-dot ant-dot-2"></div>
<div class="ant-spinner-dot ant-dot-3"></div>
<div class="ant-spinner-dot ant-dot-4"></div>
</div>
<div class="loader-text">
<span class="msg" data-uiText="defaultLoadingText">${this.uiText.defaultLoadingText}</span>
<span class="dot">.</span><span class="dot">.</span><span class="dot">.</span>
</div>
</div>
<div class="breadcrumbs" id="breadcrumbTrail"></div>
<div class="columns-wrapper">
<div class="col folder-tree" id="folderCol"></div>
<div class="col" id="previewCol">
<div class="thumb-grid" id="thumbGrid">
<div class="empty-preview">
<i class="bi bi-folder-x"></i>
<div data-uiText="noFolder">${this.uiText.noFolder}</div>
</div>
</div>
<div class="drop-zone" id="dropZone">
<div class="upload-wrapper">
<div class="upload-list"></div>
</div>
<input type="file" accept="*" multiple hidden id="uploadInput" style="display:none">
<button class="upload-btn" id="uploadBtn" data-uiText="upload">${this.uiText.upload}</button>
<p id="uploadText" data-uiText="dragAndDropText">${this.uiText.dragAndDropText}</p>
</div>
</div>
<div class="col preview" id="imagePreview">
<div class="preview-empty">
<i class="bi bi-file-earmark-x"></i>
<div data-uiText="noFile"> ${this.uiText.noFile}</div>
</div>
</div>
</div>
</div>
</div>
`);
this.modal = this.container.querySelector("#mediaModal");
this.backdrop = this.container.querySelector("#modalBackdrop");
this.folderCol = this.container.querySelector("#folderCol");
this.thumbGrid = this.container.querySelector("#thumbGrid");
this.previewCol = this.container.querySelector("#imagePreview");
this.uploadBtn = this.container.querySelector("#uploadBtn");
this.uploadInput = this.container.querySelector("#uploadInput");
this.breadcrumbTrail = this.container.querySelector("#breadcrumbTrail");
this.toggleBtn = document.getElementById(this.btnId);
// this.themeBtn = this.container.querySelector("#themeToggle");
this.closeBtn = this.container.querySelector("#modalClose");
// Insert search UI just after initializing `this.thumbGrid`
const searchWrapper = document.createElement("div");
searchWrapper.className = "thumb-search-wrapper";
this.searchInput = document.createElement("input");
this.searchInput.className = "thumb-search-input";
this.searchInput.type = "search";
this.searchInput.dataset.uiText = "searchPlaceholder";
this.searchInput.placeholder = this.uiText.searchPlaceholder;
const clearBtn = document.createElement("i");
clearBtn.className = "bi bi-x-lg clear-search hidden";
const searchIcon = document.createElement("i");
searchIcon.className = "bi bi-search trigger-search";
// Bind search behavior
const updateClearBtn = () => {
clearBtn.classList.toggle("hidden", !this.searchInput.value.trim());
};
const applySearch = () => {
this.searchTerm = this.searchInput.value.trim();
this._renderThumbnails();
};
this.searchInput.oninput = updateClearBtn;
this.searchInput.onkeydown = (e) => {
if (e.key === "Enter")
applySearch();
};
this.searchInput.onblur = applySearch;
searchIcon.onclick = applySearch;
clearBtn.onclick = () => {
this.searchInput.value = "";
this.searchTerm = "";
updateClearBtn();
this._renderThumbnails();
};
searchWrapper.append(this.searchInput, searchIcon, clearBtn);
this.thumbGrid.insertAdjacentElement("beforebegin", searchWrapper);
}
_bindEvents() {
this.toggleBtn.onclick = () => {
this.modal.classList.add("show");
this.backdrop.classList.add("show");
if (!this.isReady) {
this._toggleLoader(true, this.uiText.loadingFolderStructure);
}
};
this.closeBtn.onclick = () => {
this.modal.classList.remove("show");
this.backdrop.classList.remove("show");
};
// Upload btn
this.uploadBtn.onclick = () => this.uploadInput.click();
this.uploadInput.onchange = async (e) => {
const files = e.target.files;
await this._handleFilesUpload(files);
};
if (!this.container)
return;
// DragAndDrop
this.dropZone = this.container.querySelector("#dropZone");
this.dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
this.dropZone.classList.add("dragover");
});
this.dropZone.addEventListener("dragleave", () => {
this.dropZone.classList.remove("dragover");
});
this.dropZone.addEventListener("drop", async (e) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
await this._handleFilesUpload(files);
this.dropZone.classList.remove("dragover");
});
// Theme toggle
// this.themeBtn.onclick = () => {
// document.body.classList.toggle("dark-mode");
// document.body.classList.toggle("light-mode");
// this.themeBtn.textContent = document.body.classList.contains("light-mode")
// ? "Switch to Night Mode"
// : "Switch to Day Mode";
// };
}
_toggleLoader(isShow = true, msg = "Loading") {
if (!this.container)
return;
const loader = this.container.querySelector("#media-loader");
if (!loader)
return;
const msgSpan = loader.querySelector(".loader-text .msg");
if (!msgSpan)
return;
msgSpan.textContent = msg;
if (isShow) {
loader.classList.add("show-loader");
loader.classList.remove("hide-loader");
}
else {
loader.classList.remove("show-loader");
loader.classList.add("hide-loader");
this.container.querySelector(".loading-in-btn")?.classList.add("hidden");
}
}
_expandToPath(path) {
if (!this.openFolders)
this.openFolders = new Set();
const parts = path.split("/").filter(Boolean);
let accumulated = "/";
for (let i = 0; i < parts.length; i++) {
accumulated += parts[i] + "/";
this.openFolders.add(accumulated.replace(/\/\//g, "/").replace(/\/\//g, "/"));
}
}
async _createFolder() {
// const folderName = prompt("Enter new folder name:");
const folderName = await this._customPrompt(this.uiText.newFolderTitle || "");
if (!folderName)
return;
this._toggleLoader(true, this.uiText.loadingFolderCreation);
const newPath = this.currentPath + folderName + "/";
const sanitizedPath = newPath.replace(/\/\//g, "/");
const res = await fetch(`${this.API_URL}/media/folder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: (this.basePath + "/" + sanitizedPath)
.replace(/\/\//g, "/")
.replace(/\/\//g, "/"),
}),
});
if (res.ok) {
this._ensureFolderPath(sanitizedPath);
this.currentPath = sanitizedPath;
this._expandToPath(sanitizedPath);
this.selectedFile = null;
this._renderFolders();
this._renderThumbnails();
this._renderPreview();
this._notify(this.uiText.notifFolderCreateSuccess || "", "success");
}
else {
this._notify(this.uiText.notifFolderCreateError || "", "error");
}
this._toggleLoader(false);
}
async _handleFilesUpload(files) {
if (!files || !files.length || !this.container)
return;
const uploadBtn = this.container.querySelector("#uploadBtn");
const uploadText = this.container.querySelector("#uploadText");
if (!uploadBtn || !uploadText)
return;
uploadBtn.classList.add("hidden");
uploadText.classList.add("hidden");
const uploadWrapper = this.dropZone.querySelector(".upload-wrapper");
const uploadZone = this.dropZone.querySelector(".upload-list");
uploadWrapper.classList.add("show-upload");
uploadZone.innerHTML = ""; // clear old content
const formUploads = Array.from(files);
const placeholders = new Map();
// Step 1: create all placeholders first
for (const file of formUploads) {
const placeholder = document.createElement("div");
placeholder.className = "upload-skeleton";
placeholder.innerHTML = `
<div class="file-info">${file.name}</div>
<div class="progress-bar"><div class="bar"></div></div>
`;
uploadZone.appendChild(placeholder);
placeholders.set(file.name, placeholder);
}
// Step 2: perform all uploads in parallel
const uploads = formUploads.map((file) => {
const formData = new FormData();
formData.append("file", file);
formData.append("folder", (this.basePath + "/" + this.currentPath)
.replace(/\/\//g, "/")
.replace(/\/\//g, "/"));
return fetch(`${this.API_URL}/media/upload`, {
method: "POST",
body: formData,
})
.then(async (res) => {
if (!res.ok)
throw new Error("Upload failed");
const { filename } = await res.json();
this._addFile(this.currentPath, filename);
const isImage = this._isImage(filename);
const result = document.createElement("div");
result.className = "upload-result";
result.innerHTML = isImage
? `<img src="${this.cdnUrl}/${this.currentPath}${filename}" />`
: `<i class="bi ${this._getFileTypeIcon(filename)} file-icon"></i>`;
const check = document.createElement("i");
check.className = "bi bi-check-circle-fill success-icon";
result.appendChild(check);
const skeleton = placeholders.get(file.name);
if (skeleton) {
uploadZone.replaceChild(result, skeleton);
}
this._renderThumbnails();
})
.catch(() => {
const skeleton = placeholders.get(file.name);
if (skeleton) {
skeleton.classList.add("error");
skeleton.innerHTML += `<div class="error-text">${this.uiText.failed}</div>`;
}
});
});
// Step 3: Wait for all to settle, then hide loader
Promise.allSettled(uploads).then(() => {
this._notify(this.uiText.uploadSuccess ?? "", "success");
setTimeout(() => {
uploadWrapper.classList.remove("show-upload");
uploadBtn.classList.remove("hidden");
uploadText.classList.remove("hidden");
setTimeout(() => {
uploadZone.innerHTML = "";
}, 1000);
}, 1500);
});
}
async _uploadFile(file) {
this._toggleLoader(true, this.uiText.loadingUpload);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", (this.basePath + "/" + this.currentPath)
.replace(/\/\//g, "/")
.replace(/\/\//g, "/"));
const response = await fetch(`${this.API_URL}/media/upload`, {
method: "POST",
body: formData,
});
if (response.ok) {
const r = await response.json();
// this._getFiles(this.currentPath).push(r.filename);
this._addFile(this.currentPath, r.filename);
this.selectedFile = r.filename;
this._renderThumbnails();
}
else {
alert(this.uiText.uploadFailed);
}
this._toggleLoader(false);
}
_ensureFolderPath(path) {
const parts = path
.replace(/^\/|\/$/g, "")
.split("/")
.filter(Boolean);
let node = this.folderTree["/"];
for (const part of parts) {
if (!node[part]) {
node[part] = {};
}
node = node[part];
}
if (!node.files) {
node.files = [];
}
return node;
}
_getFolderNode(path) {
const parts = path
.replace(/^\/|\/$/g, "")
.split("/")
.filter(Boolean);
let node = this.folderTree["/"];
for (const part of parts) {
if (!node[part])
return null;
node = node[part];
}
return node;
}
_addFile(path, filename) {
const node = this._ensureFolderPath(path);
if (!node.files.includes(filename)) {
node.files.push(filename);
}
}
_deleteFile(path, filename) {
const node = this._ensureFolderPath(path);
if (node.files) {
node.files = node.files.filter((f) => f !== filename);
}
}
_getFiles(path) {
const node = this._getFolderNode(path);
return (node?.files?.slice().sort((a, b) => {
const aNum = parseInt(a.split("-")[0], 10);
const bNum = parseInt(b.split("-")[0], 10);
return bNum - aNum;
}) || []);
}
_renderFolders(tree = null) {
this.folderCol.innerHTML = "";
const newFolderBtn = document.createElement("button");
newFolderBtn.innerHTML = `<i class="bi bi-folder-plus"></i> ${this.uiText.newFolder}`;
newFolderBtn.dataset.uiText = `newFolder`;
newFolderBtn.className = "upload-btn";
newFolderBtn.onclick = () => this._createFolder();
this.folderCol.prepend(newFolderBtn);
// 🏠 Home Folder Button
const homeDiv = document.createElement("div");
homeDiv.className = "folder-label home-folder";
homeDiv.innerHTML = `<i class="bi bi-house-door-fill"></i> <span data-uiText="home">${this.uiText.home}</span>`;
homeDiv.onclick = () => {
this.currentPath = "/";
this.selectedFile = null;
this._renderFolders();
this._renderThumbnails();
this._renderPreview();
this._renderBreadcrumbs();
};
// Highlight if selected
if (this.currentPath === "/")
homeDiv.classList.add("active");
this.folderCol.appendChild(homeDiv);
if (!tree) {
tree = this._buildTree(this.folderTree["/"], "/");
}
this.folderCol.appendChild(tree);
this._renderBreadcrumbs();
}
_buildTree(node, path) {
const ul = document.createElement("ul");
ul.className = "folder-tree-parrent";
Object.entries(node).forEach(([key, value]) => {
if (key === "files" || key === "_hasContent")
return;
const li = document.createElement("li");
const folderPath = `/${path}${key}/`.replace(/\/\//g, "/");
const label = document.createElement("div");
label.className = "folder-label";
label.dataset.path = folderPath;
// const hasChildren =
// value._hasContent ||
// Object.keys(value).some((k) => k !== "files" && k !== "_hasContent");
const hasChildren = typeof value === "object" &&
value !== null &&
("_hasContent" in value ||
Object.keys(value).some((k) => k !== "files" && k !== "_hasContent"));
const shouldShow = this.openFolders.has(folderPath) || this.currentPath === folderPath;
const caret = document.createElement("i");
caret.className = hasChildren ? "caret bi bi-caret-right-fill" : "caret";
const icon = document.createElement("i");
icon.className = "bi bi-folder";
const text = document.createElement("span");
text.textContent = key;
label.append(caret, icon, text);
li.appendChild(label);
let children;
if (hasChildren || shouldShow) {
if (isFolderNode(value)) {
children = this._buildTree(value, folderPath);
children.style.display = shouldShow ? "block" : "none";
if (shouldShow)
caret.classList.add("rotate");
li.appendChild(children);
}
}
label.onclick = async () => {
// this._toggleLoader(true, `Loading folder: ${key}`);
const newPath = `/${path}${key}/`.replace(/\/\//g, "/");
this.currentPath = newPath;
this.selectedFile = null;
// const folderNode = this._ensureFolderPath(newPath);
// const alreadyLoaded = !("_hasContent" in folderNode);
// if (!alreadyLoaded) {
// const content = await this._fetchFolderContent(newPath);
// if (content) {
// folderNode.files = content.files || [];
// for (const f of content.folders || []) {
// if (!folderNode[f.name]) folderNode[f.name] = {};
// if (f.hasContent) folderNode[f.name]._hasContent = true;
// }
// delete folderNode._hasContent;
// }
// }
// const newTree = this._buildTree(folderNode, path);
// console.log('New tree:', newTree );
// label.nextElementSibling?.replaceWith(newTree);
if (hasChildren) {
const open = caret.classList.toggle("rotate");
children.style.display = open ? "block" : "none";
const folderPath = `/${path}${key}/`.replace(/\/\//g, "/");
if (open) {
this.openFolders.add(folderPath);
}
else {
this.openFolders.delete(folderPath);
}
}
this._renderThumbnails();
this._renderPreview();
this._renderBreadcrumbs();
if (!this.container)
return;
this.container.querySelectorAll(".active-folder").forEach((el) => {
el.classList.remove("active-folder");
});
label.classList.add("active-folder");
// this._toggleLoader(false);
};
if (this.currentPath === folderPath) {
label.classList.add("active-folder");
}
ul.appendChild(li);
});
return ul;
}
_renderBreadcrumbs() {
const parts = this.currentPath.split("/").filter(Boolean);
let path = "/";
const spanEls = [];
spanEls.push(`<span data-path="/"><i class="bi bi-house-door-fill"></i> ${this.uiText.home}</span>`);
for (let i = 0; i < parts.length; i++) {
path += parts[i] + "/";
spanEls.push(`<span class="sep">/</span><span data-path="${path}">${parts[i]}</span>`);
}
this.breadcrumbTrail.innerHTML = spanEls.join("");
[...this.breadcrumbTrail.querySelectorAll("span[data-path]")].forEach((span) => {
span.onclick = () => {
this.currentPath = span.dataset.path;
this.selectedFile = null;
this._renderThumbnails();
this._renderPreview();
this._renderBreadcrumbs();
if (!this.container)
return;
this.container.querySelectorAll(".active-folder").forEach((el) => {
el.classList.remove("active-folder");
});
const _label = this.folderCol.querySelector(`.folder-label[data-path="${this.currentPath}"]`);
if (_label)
_label.classList.add("active-folder");
};
});
}
_renderThumbnails(searchValue = "") {
const files = this._getFiles(this.currentPath);
this.thumbGrid.innerHTML = "";
if (files.length === 0) {
const emptyMsg = document.createElement("div");
emptyMsg.className = "empty-preview";
emptyMsg.innerHTML = `
<i class="bi bi-folder-x"></i>
<div data-uiText="noFileInFolder">${this.uiText.noFileInFolder}</div>
`;
this.thumbGrid.appendChild(emptyMsg);
return;
}
const term = this.searchTerm.toLowerCase();
const filtered = !term
? files
: files.filter((f) => f.toLowerCase().includes(term));
if (!filtered.length && term) {
const empty = document.createElement("div");
empty.className = "empty-preview";
empty.innerHTML = `
<i class="bi bi-folder-x empty-icon"></i>
<div class="empty-text" data-uiText="noFileFound">${this.uiText.noFileFound}</div>
`;
this.thumbGrid.appendChild(empty);
return;
}
filtered.forEach((file, idx) => {
const div = document.createElement("div");
div.className = "thumb";
if (this.selectedFile === file)
div.classList.add("selected");
// Placeholder shimmer element
const skeleton = document.createElement("div");
skeleton.className = "thumbnail-skeleton";
div.appendChild(skeleton);
if (this._isImage(file)) {
const img = document.createElement("img");
const src = `${this.cdnUrl}/${this.currentPath}/${file}`
.replace(/\/\//g, "/")
.replace(/\/\//g, "/")
.replace(/\/\//g, "/");
img.src = src;
img.style.opacity = "0";
img.onload = () => {
skeleton.remove();
img.style.opacity = "1";
};
div.append(img);
}
else {
const icon = document.createElement("i");
icon.className = `bi ${this._getFileTypeIcon(file)} file-icon`;
skeleton.remove();
div.append(icon);
}
const actions = document.createElement("div");
actions.className = "actions";
if (this._isImage(file)) {
const eye = document.createElement("i");
eye.className = "bi bi-eye";
eye.onclick = (e) => {
e.stopPropagation();
this.selectedFile = file;
const imgIndex = files
.filter((f) => this._isImage(f))
.indexOf(file);
console.log('ast img', imgIndex, file, files, filtered);
this._showImageModal(imgIndex);
this._renderPreview();
};
actions.append(eye);
}
const del = document.createElement("i");
del.className = "bi bi-trash";
del.onclick = async (e) => {
e.stopPropagation();
// const confirmed = confirm(`Delete "${file}"?`);
const confirmed = await this._customConfirm(`${this.uiText.deleteConfirmationText} "${file}"`, true);
if (!confirmed)
return;
this._toggleLoader(true, `${this.uiText.loadingDelete} : ${file}`);
const __path = `${(this.basePath + "/" + this.currentPath)
.replace(/\/\//g, "/")
.replace(/\/\//g, "/")}${file}`.replace(/\/\//g, "/");
const res = await fetch(`${this.API_URL}/media/delete?path=${encodeURIComponent(__path)}`, {
method: "DELETE",
});
if (res.ok) {
// this._getFiles(this.currentPath).splice(idx, 1);
this._deleteFile(this.currentPath, file);
if (this.selectedFile === file)
this.selectedFile = null;
this._renderThumbnails();
this._renderPreview();
this._toggleLoader(false);
this._notify(this.uiText.deleteSuccess ?? "", "success");
}
else {
this._notify(this.uiText.deleteFailed ?? "", "error");
this._toggleLoader(false);
}
};
actions.append(del);
div.append(actions);
div.onclick = () => {
this.selectedFile = file;
this._renderThumbnails();
this._renderPreview();
};
const nameLabel = document.createElement("div");
nameLabel.className = "thumb-filename";
nameLabel.textContent = file;
div.appendChild(nameLabel);
this.thumbGrid.appendChild(div);
});
}
async _fetchFolderTree() {
const res = await fetch(`${this.API_URL}/media/all_tree?base=${encodeURIComponent(this.basePath)}`);
if (!res.ok)
return;
const data = await res.json();
this.folderTree = data;
this._renderFolders();
}
async _fetchFolderContent(path) {
const res = await fetch(`${this.API_URL}/media/tree?path=${encodeURIComponent(path)}`);
if (!res.ok)
return null;
return await res.json();
}
joinUrlParts(...parts) {
return parts
.map((part, index) => {
if (index === 0)
return part.replace(/\/+$/, ""); // keep protocol slashes like https://
return part.replace(/^\/+|\/+$/g, ""); // strip all leading/trailing slashes from the rest
})
.filter(Boolean)
.join("/");
}
_renderPreview() {
this.previewCol.innerHTML = "";
if (!this.selectedFile) {
const empty = document.createElement("div");
empty.className = "preview-empty";
empty.innerHTML = `
<i class="bi bi-file-earmark-x"></i>
<div data-uiText="noFileSelected">${this.uiText.noFileSelected}</div>
`;
this.previewCol.appendChild(empty);
return;
}
// Title
const title = document.createElement("div");
title.className = "preview-title";
title.innerHTML = `<i class="bi bi-file-earmark-check"></i> ${this.selectedFile}`;
this.previewCol.appendChild(title);
const showCopySuccess = (container, message = this.uiText.copied) => {
if (container.classList.contains("copied")) {
return;
}
container.classList.add("copied");
const msg = document.createElement("div");
msg.className = "copy-msg show";
msg.innerHTML = `<i class="bi bi-check-circle-fill"></i> ${message}`;
container.appendChild(msg);
setTimeout(() => {
msg.classList.remove("show");
container.classList.remove("copied");
setTimeout(() => msg.remove(), 300);
}, 2000);
};
const fileURL = this.joinUrlParts(this.cdnUrl, this.currentPath, this.selectedFile);
console.log("here", this.cdnUrl, fileURL);
let imgSnippet;
let copyImgBtn;
if (this._isImage(this.selectedFile)) {
const imgTag = `<img src="${fileURL}" />`;
// Create <img> code block
imgSnippet = document.createElement("div");
imgSnippet.className = "code-snippet";
imgSnippet.innerHTML = `
<code>${imgTag.replace(/</g, "<").replace(/>/g, ">")}</code>
<i class="bi bi-clipboard copy-icon" title="Copy"></i>
`;
imgSnippet.querySelector(".copy-icon").onclick = () => {
this._copyToClipboard(imgTag);
showCopySuccess(imgSnippet, this.uiText.imgCopied);
};
// Img tag copy btn
copyImgBtn = document.createElement("button");
copyImgBtn.className = "btn-block";
copyImgBtn.innerHTML = `<i class="bi bi-code-slash"></i> ${this.uiText.imgCopyBtnText}`;
copyImgBtn.onclick = () => {
this._copyToClipboard(imgTag);
showCopySuccess(imgSnippet, this.uiText.imgCopied);
};
}
// Create URL code block
const urlSnippet = document.createElement("div");
urlSnippet.className = "code-snippet";
urlSnippet.innerHTML = `
<code>${fileURL}</code>
<i class="bi bi-clipboard copy-icon" title="Copy"></i>
`;
const copy_ico = urlSnippet.querySelector(".copy-icon");
if (copy_ico) {
copy_ico.onclick = () => {
this._copyToClipboard(fileURL);
showCopySuccess(urlSnippet, this.uiText.urlCopied);
};
}
// Buttons
const copyUrlBtn = document.createElement("button");
copyUrlBtn.className = "btn-block";
copyUrlBtn.innerHTML = `<i class="bi bi-link"></i> ${this.uiText.urlCopyBtnText}`;
copyUrlBtn.onclick = () => {
this._copyToClipboard(fileURL);
showCopySuccess(urlSnippet, this.uiText.urlCopied);
};
const downloadBtn = document.createElement("button");
downloadBtn.className = "btn-block";
downloadBtn.innerHTML = `<i class="bi bi-download"></i> ${this.uiText.download}`;
downloadBtn.onclick = () => {
const a = document.createElement("a");
a.href = fileURL;
a.target = "_blank";
a.download = this.selectedFile + ".jpg";
a.click();
};
if (this._isImage(this.selectedFile)) {
this.previewCol.appendChild(imgSnippet);
this.previewCol.appendChild(copyImgBtn);
}
this.previewCol.append(urlSnippet, copyUrlBtn, downloadBtn);
}
_copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => { });
}
else {
// fallback for insecure context
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
}
catch (err) {
console.error("Fallback copy failed:", err);
}
document.body.removeChild(textarea);
}
}
_renderImageViewerModal() {
const modal = document.createElement("div");
modal.id = "image-viewer-modal";
modal.className = "image-modal hidden";
modal.innerHTML = `
<div class="image-modal-content">
<span class="image-modal-close">×</span>
<div class="image-modal-nav">
<i class="bi bi-caret-left-fill nav-left"></i>
<div class="lightbox-skeleton hidden" id="image-modal-skeleton"></div>
<img src="" alt="Full preview" class="lightbox-image hidden" id="image-viewer-img" />
<i class="bi bi-caret-right-fill nav-right"></i>
</div>
<div class="image-modal-thumbs" id="image-modal-thumbs"></div>
</div>
`;
document.body.appendChild(modal);
this.imageModal = modal;
this.imageModalImg = modal.querySelector("#image-viewer-img") || undefined;
this.imageModalThumbs =
modal.querySelector("#image-modal-thumbs") || undefined;
this.imageModalSkeleton =
modal.querySelector("#image-modal-skeleton") || undefined;
if (!modal)
return;
const imgClose = modal.querySelector(".image-modal-close");
if (imgClose)
imgClose.onclick = () => this._closeImageModal();
modal.onclick = (e) => {
if (e.target === modal)
this._closeImageModal();
};
const navL = modal.querySelector(".nav-left");
const navR = modal.querySelector(".nav-right");
if (navL)
navL.onclick = () => this._navigateImage(-1);
if (navR)
navR.onclick = () => this._navigateImage(1);
}
_showImageModal(index) {
const term = this.searchTerm.toLowerCase();
const files = this._getFiles(this.currentPath);
const filtered = !term
? files
: files.filter((f) => f.toLowerCase().includes(term));
this.modalFiles = filtered.filter((f) => this._isImage(f)) || [];
this.modalIndex = index;
this._updateImageModal();
if (!this.imageModal)
return;
this.imageModal.classList.remove("hidden");
this.imageModal.classList.add("show");
}
_closeImageModal() {
if (!this.imageModal)
return;
this.imageModal.classList.remove("show");
setTimeout(() => {
if (!this.imageModal)
return;
this.imageModal.classList.add("hidden");
}, 300);
}
_navigateImage(direction) {
if (!this.modalFiles)
return;
this.modalIndex =
(this.modalIndex + direction + this.modalFiles.length) %
this.modalFiles.length;
this._updateImageModal();
}
_updateImageModal() {
if (!this.modalFiles ||
this.modalIndex == undefined ||
!this.imageModalImg ||
!this.imageModalThumbs ||
!this.imageModalSkeleton)
return;
const file = this.modalFiles[this.modalIndex];
const fileName = typeof file === "string" ? file : file.name;
this.selectedFile = fileName;
this._renderThumbnails();
this._renderPreview();
const fileUrl = typeof file === "string"
? `${this.cdnUrl}${this.currentPath}${file}`
: file.preview;
// Show skeleton while loading image
this.imageModalImg.classList.add("hidden"); // hide actual image
this.imageModalSkeleton.classList.remove("hidden"); // show skeleton
this.imageModalImg.onload = () => {
if (!this.imageModalImg || !this.imageModalSkeleton)
return;
this.imageModalSkeleton.classList.add("hidden"); // hide skeleton
this.imageModalImg.classList.remove("hidden"); // show image
};
this.imageModalImg.src = fileUrl;
// Render bottom thumbnails
this.imageModalThumbs.innerHTML = "";
this.modalFiles.forEach((f, idx) => {
if (!this.imageModalThumbs)
return;
const fname = typeof f === "string" ? f : f.name;
const url = typeof f === "string"
? `${this.cdnUrl}${this.currentPath}${fname}`
: f.preview;
const thumb = document.createElement("img");
thumb.src = url;
thumb.className = "modal-thumb";
if (idx === this.modalIndex)
thumb.classList.add("active");
thumb.onclick = () => {
this.modalIndex = idx;
this._updateImageModal();
};
this.imageModalThumbs.appendChild(thumb);
});
}
_isImage(file) {
return /\.(jpe?g|png|ico|gif|bmp|webp|svg)$/i.test(file);
}
_getFileTypeIcon(filename) {
const pop = filename.split(".").pop();
if (!pop)
return "bi-file-earmark";
const ext = pop.toLowerCase();
if (["pdf"].includes(ext))
return "bi-file-earmark-pdf";
if (["doc", "docx"].includes(ext))
return "bi-file-earmark-word";
if (["xls", "xlsx", "csv"].includes(ext))
return "bi-file-earmark-excel";
if (["zip", "rar", "tar", "gz", "7z"].includes(ext))
return "bi-file-earmark-zip";
if (["exe", "apk", "msi", "dmg", "app"].includes(ext))
return "bi-gear";
if (["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv"].includes(ext))
return "bi-file-earmark-play";
if (this._isImage(filename))
return "bi-file-image";
return "bi-file-earmark";
}
_notify(message, type = "info") {
const iconMap = {
success: "bi-check-circle-fill",
error: "bi-x-circle-fill",
info: "bi-info-circle-fill",
};
const note = document.createElement("div");
note.className = `media-toast ${type}`;
note.innerHTML = `
<i class="bi ${iconMap[type] || "bi-info-circle-fill"} toast-icon"></i>
<span class="toast-message">${message}</span>
`;
document.body.appendChild(note);
setTimeout(() => note.classList.add("show"), 10);
setTimeout(() => note.classList.remove("show"), 3000);
setTimeout(() => note.remove(), 3500);
}
_customConfirm(message, deleteConfirmation = false) {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "custom-modal-backdrop";
modal.innerHTML = `
<div class="custom-modal">
<h3>${message}</h3>
<div class="custom-modal-actions">
<button class="btn-confirm ${deleteConfirmation ? "deleteBtn" : ""}" data-uiText="confirmBtnText">${deleteConfirmation ? this.uiText.delete : this.uiText.confirmBtnText}</button>
<button class="btn-cancel" data-uiText="cancelBtnText">${this.uiText.cancelBtnText}</button>
</div>
</div>
`;
document.body.appendChild(modal);
const confirmBtn = modal.querySelector(".btn-confirm");
if (confirmBtn)
confirmBtn.onclick = () => {
modal.remove();
resolve(true);
};
const cancelBtn = modal.querySelector(".btn-cancel");
if (cancelBtn)
cancelBtn.onclick = () => {
modal.remove();
resolve(false);
};
});
}
_customPrompt(message) {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "custom-modal-backdrop";
modal.innerHTML = `
<div class="custom-modal">
<h3>${message}</h3>
<div class="custom-input-container">
<i class="bi bi-folder-plus"></i>
<input type="text" class="custom-input" />
</div>
<div class="custom-modal-actions">
<button class="btn-confirm">${this.uiText.promptBtnText}</button>
<button class="btn-cancel">${this.uiText.cancelPropmtBtnText}</button>
</div>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector("input");
if (!input)
return;
const confirmBtn = modal.querySelector(".btn-confirm");
if (confirmBtn)
confirmBtn.onclick = () => {
const val = input.value.trim();
modal.remove();
resolve(val || null);
};
const cancelBtn = modal.querySelector(".btn-cancel");
if (cancelBtn)
cancelBtn.onclick = () => {
modal.remove();
resolve(null);
};
input.focus();
});
}
}