1132 lines
36 KiB
JavaScript
1132 lines
36 KiB
JavaScript
|
|
/**
|
||
|
|
* Modern Media Library - Reusable Component
|
||
|
|
* Used across: Dashboard, Homepage, Products, Portfolio, Blog, Custom Pages, Settings
|
||
|
|
*/
|
||
|
|
|
||
|
|
class MediaLibrary {
|
||
|
|
constructor(options = {}) {
|
||
|
|
this.options = {
|
||
|
|
multiple: options.multiple || false,
|
||
|
|
onSelect: options.onSelect || null,
|
||
|
|
selectMode: options.selectMode || false,
|
||
|
|
containerId: options.containerId || "media-library-modal",
|
||
|
|
...options,
|
||
|
|
};
|
||
|
|
|
||
|
|
this.currentFolder = null;
|
||
|
|
this.folders = [];
|
||
|
|
this.files = [];
|
||
|
|
this.selectedItems = [];
|
||
|
|
this.viewMode = localStorage.getItem("mediaLibraryView") || "grid";
|
||
|
|
this.sortBy = localStorage.getItem("mediaLibrarySort") || "date";
|
||
|
|
this.searchQuery = "";
|
||
|
|
this.draggedItem = null;
|
||
|
|
|
||
|
|
this.modal = null;
|
||
|
|
this.initialized = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Initialize the media library modal
|
||
|
|
init() {
|
||
|
|
if (this.initialized) return;
|
||
|
|
|
||
|
|
// Create modal HTML
|
||
|
|
this.createModal();
|
||
|
|
this.bindEvents();
|
||
|
|
this.initialized = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
createModal() {
|
||
|
|
// Remove existing modal if any
|
||
|
|
const existing = document.getElementById(this.options.containerId);
|
||
|
|
if (existing) existing.remove();
|
||
|
|
|
||
|
|
const modalHtml = `
|
||
|
|
<div class="modal fade media-library-modal" id="${
|
||
|
|
this.options.containerId
|
||
|
|
}" tabindex="-1" data-bs-backdrop="static">
|
||
|
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-fullscreen-lg-down">
|
||
|
|
<div class="modal-content">
|
||
|
|
<div class="modal-header media-library-header">
|
||
|
|
<div class="header-left">
|
||
|
|
<button type="button" class="btn btn-outline-light btn-sm back-btn" id="backBtn" style="display: none;">
|
||
|
|
<i class="bi bi-arrow-left"></i> Back
|
||
|
|
</button>
|
||
|
|
<div class="title-breadcrumb">
|
||
|
|
<h5 class="modal-title">
|
||
|
|
<i class="bi bi-images"></i>
|
||
|
|
Media Library
|
||
|
|
</h5>
|
||
|
|
<nav class="breadcrumb-nav" aria-label="breadcrumb">
|
||
|
|
<ol class="breadcrumb mb-0" id="folderBreadcrumb">
|
||
|
|
<li class="breadcrumb-item active">Root</li>
|
||
|
|
</ol>
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="header-right">
|
||
|
|
<div class="view-toggle btn-group" role="group">
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm ${
|
||
|
|
this.viewMode === "grid" ? "active" : ""
|
||
|
|
}" data-view="grid" title="Grid view">
|
||
|
|
<i class="bi bi-grid-3x3-gap-fill"></i>
|
||
|
|
</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm ${
|
||
|
|
this.viewMode === "list" ? "active" : ""
|
||
|
|
}" data-view="list" title="List view">
|
||
|
|
<i class="bi bi-list-ul"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="modal-body media-library-body">
|
||
|
|
<div class="media-library-toolbar">
|
||
|
|
<div class="toolbar-left">
|
||
|
|
<button type="button" class="btn btn-primary" id="uploadBtn">
|
||
|
|
<i class="bi bi-cloud-upload"></i> Upload
|
||
|
|
</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary" id="newFolderBtn">
|
||
|
|
<i class="bi bi-folder-plus"></i> New Folder
|
||
|
|
</button>
|
||
|
|
<div class="divider"></div>
|
||
|
|
<div class="search-box">
|
||
|
|
<i class="bi bi-search"></i>
|
||
|
|
<input type="text" placeholder="Search files..." id="mediaSearchInput">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="toolbar-right">
|
||
|
|
<select class="form-select form-select-sm" id="sortSelect">
|
||
|
|
<option value="date" ${
|
||
|
|
this.sortBy === "date" ? "selected" : ""
|
||
|
|
}>Date (Newest)</option>
|
||
|
|
<option value="date-asc" ${
|
||
|
|
this.sortBy === "date-asc" ? "selected" : ""
|
||
|
|
}>Date (Oldest)</option>
|
||
|
|
<option value="name" ${
|
||
|
|
this.sortBy === "name" ? "selected" : ""
|
||
|
|
}>Name (A-Z)</option>
|
||
|
|
<option value="name-desc" ${
|
||
|
|
this.sortBy === "name-desc" ? "selected" : ""
|
||
|
|
}>Name (Z-A)</option>
|
||
|
|
<option value="size" ${
|
||
|
|
this.sortBy === "size" ? "selected" : ""
|
||
|
|
}>Size (Largest)</option>
|
||
|
|
<option value="size-asc" ${
|
||
|
|
this.sortBy === "size-asc" ? "selected" : ""
|
||
|
|
}>Size (Smallest)</option>
|
||
|
|
</select>
|
||
|
|
<span class="selected-count" id="selectedCount" style="display: none;">
|
||
|
|
<span class="count">0</span> selected
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="upload-drop-zone" id="uploadDropZone">
|
||
|
|
<div class="drop-zone-content">
|
||
|
|
<i class="bi bi-cloud-arrow-up"></i>
|
||
|
|
<p>Drag & drop files here or <span class="browse-link">browse</span></p>
|
||
|
|
<small>Supports: JPG, PNG, GIF, WebP, MP4, WebM, MOV (Max 100MB each)</small>
|
||
|
|
</div>
|
||
|
|
<input type="file" id="fileInput" multiple accept="image/*,video/*,.jpg,.jpeg,.png,.gif,.webp,.bmp,.tiff,.tif,.svg,.ico,.avif,.heic,.heif,.mp4,.webm,.mov,.avi,.mkv" hidden>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="upload-progress" id="uploadProgress" style="display: none;">
|
||
|
|
<div class="progress-header">
|
||
|
|
<span>Uploading...</span>
|
||
|
|
<span class="progress-percent">0%</span>
|
||
|
|
</div>
|
||
|
|
<div class="progress">
|
||
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="media-content ${
|
||
|
|
this.viewMode === "list" ? "list-view" : "grid-view"
|
||
|
|
}" id="mediaContent">
|
||
|
|
<div class="loading-spinner">
|
||
|
|
<div class="spinner-border text-primary" role="status">
|
||
|
|
<span class="visually-hidden">Loading...</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="modal-footer media-library-footer">
|
||
|
|
<div class="footer-left">
|
||
|
|
<button type="button" class="btn btn-outline-danger" id="deleteSelectedBtn" style="display: none;">
|
||
|
|
<i class="bi bi-trash"></i> Delete Selected
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="footer-right">
|
||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
|
|
<button type="button" class="btn btn-primary" id="selectFilesBtn" ${
|
||
|
|
this.options.selectMode ? "" : 'style="display: none;"'
|
||
|
|
}>
|
||
|
|
<i class="bi bi-check2"></i> Select${
|
||
|
|
this.options.multiple ? " Files" : " File"
|
||
|
|
}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Rename Modal -->
|
||
|
|
<div class="modal fade" id="renameModal" tabindex="-1">
|
||
|
|
<div class="modal-dialog modal-dialog-centered">
|
||
|
|
<div class="modal-content">
|
||
|
|
<div class="modal-header">
|
||
|
|
<h5 class="modal-title"><i class="bi bi-pencil"></i> Rename</h5>
|
||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
|
|
</div>
|
||
|
|
<div class="modal-body">
|
||
|
|
<input type="text" class="form-control" id="renameInput" placeholder="Enter new name">
|
||
|
|
<input type="hidden" id="renameItemId">
|
||
|
|
<input type="hidden" id="renameItemType">
|
||
|
|
</div>
|
||
|
|
<div class="modal-footer">
|
||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
|
|
<button type="button" class="btn btn-primary" id="confirmRenameBtn">Rename</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Create Folder Modal -->
|
||
|
|
<div class="modal fade" id="createFolderModal" tabindex="-1">
|
||
|
|
<div class="modal-dialog modal-dialog-centered">
|
||
|
|
<div class="modal-content">
|
||
|
|
<div class="modal-header">
|
||
|
|
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> Create New Folder</h5>
|
||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
|
|
</div>
|
||
|
|
<div class="modal-body">
|
||
|
|
<input type="text" class="form-control" id="newFolderInput" placeholder="Folder name">
|
||
|
|
</div>
|
||
|
|
<div class="modal-footer">
|
||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
|
|
<button type="button" class="btn btn-primary" id="confirmCreateFolderBtn">Create</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Image Preview Modal -->
|
||
|
|
<div class="image-preview-overlay" id="imagePreviewOverlay">
|
||
|
|
<button class="preview-close" id="previewClose">×</button>
|
||
|
|
<img src="" alt="Preview" id="previewImage">
|
||
|
|
<div class="preview-info">
|
||
|
|
<span class="preview-filename" id="previewFilename"></span>
|
||
|
|
<span class="preview-size" id="previewSize"></span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
document.body.insertAdjacentHTML("beforeend", modalHtml);
|
||
|
|
this.modal = new bootstrap.Modal(
|
||
|
|
document.getElementById(this.options.containerId),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
bindEvents() {
|
||
|
|
const container = document.getElementById(this.options.containerId);
|
||
|
|
|
||
|
|
// Back button
|
||
|
|
document.getElementById("backBtn").addEventListener("click", () => {
|
||
|
|
this.navigateBack();
|
||
|
|
});
|
||
|
|
|
||
|
|
// View toggle
|
||
|
|
container.querySelectorAll(".view-toggle button").forEach((btn) => {
|
||
|
|
btn.addEventListener("click", (e) => {
|
||
|
|
this.setViewMode(e.currentTarget.dataset.view);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Sort change
|
||
|
|
document.getElementById("sortSelect").addEventListener("change", (e) => {
|
||
|
|
this.sortBy = e.target.value;
|
||
|
|
localStorage.setItem("mediaLibrarySort", this.sortBy);
|
||
|
|
this.renderContent();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Search
|
||
|
|
document
|
||
|
|
.getElementById("mediaSearchInput")
|
||
|
|
.addEventListener("input", (e) => {
|
||
|
|
this.searchQuery = e.target.value.toLowerCase();
|
||
|
|
this.renderContent();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Upload button
|
||
|
|
document.getElementById("uploadBtn").addEventListener("click", () => {
|
||
|
|
document.getElementById("fileInput").click();
|
||
|
|
});
|
||
|
|
|
||
|
|
// File input change
|
||
|
|
document.getElementById("fileInput").addEventListener("change", (e) => {
|
||
|
|
if (e.target.files.length > 0) {
|
||
|
|
this.uploadFiles(e.target.files);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Drop zone
|
||
|
|
const dropZone = document.getElementById("uploadDropZone");
|
||
|
|
dropZone.addEventListener("dragover", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
dropZone.classList.add("dragover");
|
||
|
|
});
|
||
|
|
dropZone.addEventListener("dragleave", () => {
|
||
|
|
dropZone.classList.remove("dragover");
|
||
|
|
});
|
||
|
|
dropZone.addEventListener("drop", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
dropZone.classList.remove("dragover");
|
||
|
|
if (e.dataTransfer.files.length > 0) {
|
||
|
|
this.uploadFiles(e.dataTransfer.files);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
dropZone.querySelector(".browse-link").addEventListener("click", () => {
|
||
|
|
document.getElementById("fileInput").click();
|
||
|
|
});
|
||
|
|
|
||
|
|
// New folder button
|
||
|
|
document.getElementById("newFolderBtn").addEventListener("click", () => {
|
||
|
|
document.getElementById("newFolderInput").value = "";
|
||
|
|
new bootstrap.Modal(document.getElementById("createFolderModal")).show();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create folder confirm
|
||
|
|
document
|
||
|
|
.getElementById("confirmCreateFolderBtn")
|
||
|
|
.addEventListener("click", () => {
|
||
|
|
this.createFolder();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Enter key on folder input
|
||
|
|
document
|
||
|
|
.getElementById("newFolderInput")
|
||
|
|
.addEventListener("keypress", (e) => {
|
||
|
|
if (e.key === "Enter") this.createFolder();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Rename confirm
|
||
|
|
document
|
||
|
|
.getElementById("confirmRenameBtn")
|
||
|
|
.addEventListener("click", () => {
|
||
|
|
this.confirmRename();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Enter key on rename input
|
||
|
|
document.getElementById("renameInput").addEventListener("keypress", (e) => {
|
||
|
|
if (e.key === "Enter") this.confirmRename();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Delete selected
|
||
|
|
document
|
||
|
|
.getElementById("deleteSelectedBtn")
|
||
|
|
.addEventListener("click", () => {
|
||
|
|
this.deleteSelected();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Select files button
|
||
|
|
document.getElementById("selectFilesBtn").addEventListener("click", () => {
|
||
|
|
this.confirmSelection();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Image preview close
|
||
|
|
document.getElementById("previewClose").addEventListener("click", () => {
|
||
|
|
this.closePreview();
|
||
|
|
});
|
||
|
|
document
|
||
|
|
.getElementById("imagePreviewOverlay")
|
||
|
|
.addEventListener("click", (e) => {
|
||
|
|
if (e.target.id === "imagePreviewOverlay") this.closePreview();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Escape key to close preview
|
||
|
|
document.addEventListener("keydown", (e) => {
|
||
|
|
if (e.key === "Escape") this.closePreview();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
setViewMode(mode) {
|
||
|
|
this.viewMode = mode;
|
||
|
|
localStorage.setItem("mediaLibraryView", mode);
|
||
|
|
|
||
|
|
const container = document.getElementById(this.options.containerId);
|
||
|
|
container.querySelectorAll(".view-toggle button").forEach((btn) => {
|
||
|
|
btn.classList.toggle("active", btn.dataset.view === mode);
|
||
|
|
});
|
||
|
|
|
||
|
|
const content = document.getElementById("mediaContent");
|
||
|
|
content.classList.remove("grid-view", "list-view");
|
||
|
|
content.classList.add(mode === "list" ? "list-view" : "grid-view");
|
||
|
|
}
|
||
|
|
|
||
|
|
async open() {
|
||
|
|
this.init();
|
||
|
|
this.selectedItems = [];
|
||
|
|
this.currentFolder = null;
|
||
|
|
await this.loadContent();
|
||
|
|
this.modal.show();
|
||
|
|
this.updateSelectedCount();
|
||
|
|
}
|
||
|
|
|
||
|
|
close() {
|
||
|
|
if (this.modal) {
|
||
|
|
this.modal.hide();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async loadContent() {
|
||
|
|
try {
|
||
|
|
const [foldersRes, filesRes] = await Promise.all([
|
||
|
|
fetch("/api/admin/folders", { credentials: "include" }),
|
||
|
|
fetch(
|
||
|
|
`/api/admin/uploads${
|
||
|
|
this.currentFolder
|
||
|
|
? `?folder_id=${this.currentFolder}`
|
||
|
|
: "?folder_id=null"
|
||
|
|
}`,
|
||
|
|
{ credentials: "include" },
|
||
|
|
),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const foldersData = await foldersRes.json();
|
||
|
|
const filesData = await filesRes.json();
|
||
|
|
|
||
|
|
this.folders = foldersData.folders || [];
|
||
|
|
this.files = filesData.files || [];
|
||
|
|
|
||
|
|
this.renderContent();
|
||
|
|
this.updateBreadcrumb();
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error loading media:", error);
|
||
|
|
this.showNotification("Failed to load media library", "error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
renderContent() {
|
||
|
|
const content = document.getElementById("mediaContent");
|
||
|
|
|
||
|
|
// Filter by search
|
||
|
|
let filteredFolders = this.folders.filter((f) =>
|
||
|
|
this.currentFolder
|
||
|
|
? f.parentId === this.currentFolder
|
||
|
|
: f.parentId === null,
|
||
|
|
);
|
||
|
|
let filteredFiles = this.files;
|
||
|
|
|
||
|
|
if (this.searchQuery) {
|
||
|
|
filteredFolders = filteredFolders.filter((f) =>
|
||
|
|
f.name.toLowerCase().includes(this.searchQuery),
|
||
|
|
);
|
||
|
|
filteredFiles = filteredFiles.filter(
|
||
|
|
(f) =>
|
||
|
|
f.originalName.toLowerCase().includes(this.searchQuery) ||
|
||
|
|
f.filename.toLowerCase().includes(this.searchQuery),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort files
|
||
|
|
filteredFiles = this.sortFiles(filteredFiles);
|
||
|
|
|
||
|
|
if (filteredFolders.length === 0 && filteredFiles.length === 0) {
|
||
|
|
content.innerHTML = `
|
||
|
|
<div class="empty-state">
|
||
|
|
<i class="bi bi-folder2-open"></i>
|
||
|
|
<h5>${
|
||
|
|
this.searchQuery ? "No results found" : "This folder is empty"
|
||
|
|
}</h5>
|
||
|
|
<p>${
|
||
|
|
this.searchQuery
|
||
|
|
? "Try a different search term"
|
||
|
|
: "Upload files or create a folder to get started"
|
||
|
|
}</p>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let html = "";
|
||
|
|
|
||
|
|
// Render folders first
|
||
|
|
filteredFolders.forEach((folder) => {
|
||
|
|
const isSelected = this.selectedItems.some(
|
||
|
|
(s) => s.type === "folder" && s.id === folder.id,
|
||
|
|
);
|
||
|
|
html += `
|
||
|
|
<div class="media-item folder-item ${isSelected ? "selected" : ""}"
|
||
|
|
data-type="folder" data-id="${folder.id}" data-name="${
|
||
|
|
folder.name
|
||
|
|
}"
|
||
|
|
draggable="true">
|
||
|
|
<div class="item-checkbox">
|
||
|
|
<input type="checkbox" ${isSelected ? "checked" : ""}>
|
||
|
|
</div>
|
||
|
|
<div class="item-icon">
|
||
|
|
<i class="bi bi-folder-fill"></i>
|
||
|
|
</div>
|
||
|
|
<div class="item-info">
|
||
|
|
<span class="item-name">${this.escapeHtml(folder.name)}</span>
|
||
|
|
<span class="item-meta">${folder.fileCount || 0} files</span>
|
||
|
|
</div>
|
||
|
|
<div class="item-actions">
|
||
|
|
<button class="btn-action" data-action="rename" title="Rename">
|
||
|
|
<i class="bi bi-pencil"></i>
|
||
|
|
</button>
|
||
|
|
<button class="btn-action btn-danger" data-action="delete" title="Delete">
|
||
|
|
<i class="bi bi-trash"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Render files
|
||
|
|
filteredFiles.forEach((file) => {
|
||
|
|
const isSelected = this.selectedItems.some(
|
||
|
|
(s) => s.type === "file" && s.id === file.id,
|
||
|
|
);
|
||
|
|
const fileSize = this.formatFileSize(file.size);
|
||
|
|
const isVideo = this.isVideoFile(file.originalName || file.path);
|
||
|
|
const previewHtml = isVideo
|
||
|
|
? `<div class="video-preview-placeholder"><i class="bi bi-play-circle-fill"></i><span>Video</span></div>`
|
||
|
|
: `<img src="${file.path}" alt="${file.originalName}" loading="lazy">`;
|
||
|
|
html += `
|
||
|
|
<div class="media-item file-item ${isSelected ? "selected" : ""}"
|
||
|
|
data-type="file" data-id="${file.id}" data-path="${file.path}"
|
||
|
|
data-name="${file.originalName}" data-size="${file.size}"
|
||
|
|
data-is-video="${isVideo}"
|
||
|
|
draggable="true">
|
||
|
|
<div class="item-checkbox">
|
||
|
|
<input type="checkbox" ${isSelected ? "checked" : ""}>
|
||
|
|
</div>
|
||
|
|
<div class="item-preview">
|
||
|
|
${previewHtml}
|
||
|
|
</div>
|
||
|
|
<div class="item-info">
|
||
|
|
<span class="item-name" title="${this.escapeHtml(
|
||
|
|
file.originalName,
|
||
|
|
)}">${this.escapeHtml(file.originalName)}</span>
|
||
|
|
<span class="item-meta">${fileSize}</span>
|
||
|
|
</div>
|
||
|
|
<div class="item-actions">
|
||
|
|
<button class="btn-action" data-action="preview" title="Preview">
|
||
|
|
<i class="bi bi-eye"></i>
|
||
|
|
</button>
|
||
|
|
<button class="btn-action" data-action="rename" title="Rename">
|
||
|
|
<i class="bi bi-pencil"></i>
|
||
|
|
</button>
|
||
|
|
<button class="btn-action btn-danger" data-action="delete" title="Delete">
|
||
|
|
<i class="bi bi-trash"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
|
||
|
|
content.innerHTML = html;
|
||
|
|
this.bindItemEvents();
|
||
|
|
}
|
||
|
|
|
||
|
|
bindItemEvents() {
|
||
|
|
const content = document.getElementById("mediaContent");
|
||
|
|
|
||
|
|
content.querySelectorAll(".media-item").forEach((item) => {
|
||
|
|
// Click to select
|
||
|
|
item.addEventListener("click", (e) => {
|
||
|
|
if (
|
||
|
|
e.target.closest(".item-actions") ||
|
||
|
|
e.target.closest(".item-checkbox")
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
|
||
|
|
const type = item.dataset.type;
|
||
|
|
const id = parseInt(item.dataset.id);
|
||
|
|
|
||
|
|
// Double click to open folder or preview image
|
||
|
|
if (type === "folder") {
|
||
|
|
this.currentFolder = id;
|
||
|
|
this.loadContent();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Single click to select/deselect
|
||
|
|
this.toggleSelection(item);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Checkbox click
|
||
|
|
item
|
||
|
|
.querySelector(".item-checkbox input")
|
||
|
|
.addEventListener("change", (e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
this.toggleSelection(item);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Action buttons
|
||
|
|
item.querySelectorAll(".btn-action").forEach((btn) => {
|
||
|
|
btn.addEventListener("click", (e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
const action = btn.dataset.action;
|
||
|
|
const type = item.dataset.type;
|
||
|
|
const id = parseInt(item.dataset.id);
|
||
|
|
const name = item.dataset.name;
|
||
|
|
|
||
|
|
switch (action) {
|
||
|
|
case "preview":
|
||
|
|
this.showPreview(item.dataset.path, name, item.dataset.size);
|
||
|
|
break;
|
||
|
|
case "rename":
|
||
|
|
this.showRenameModal(id, type, name);
|
||
|
|
break;
|
||
|
|
case "delete":
|
||
|
|
this.deleteItem(id, type, name);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Drag and drop
|
||
|
|
item.addEventListener("dragstart", (e) => {
|
||
|
|
this.draggedItem = {
|
||
|
|
type: item.dataset.type,
|
||
|
|
id: parseInt(item.dataset.id),
|
||
|
|
};
|
||
|
|
item.classList.add("dragging");
|
||
|
|
});
|
||
|
|
|
||
|
|
item.addEventListener("dragend", () => {
|
||
|
|
item.classList.remove("dragging");
|
||
|
|
this.draggedItem = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (item.dataset.type === "folder") {
|
||
|
|
item.addEventListener("dragover", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (this.draggedItem && this.draggedItem.type === "file") {
|
||
|
|
item.classList.add("drag-over");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
item.addEventListener("dragleave", () => {
|
||
|
|
item.classList.remove("drag-over");
|
||
|
|
});
|
||
|
|
|
||
|
|
item.addEventListener("drop", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
item.classList.remove("drag-over");
|
||
|
|
if (this.draggedItem && this.draggedItem.type === "file") {
|
||
|
|
this.moveFileToFolder(
|
||
|
|
this.draggedItem.id,
|
||
|
|
parseInt(item.dataset.id),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
toggleSelection(item) {
|
||
|
|
const type = item.dataset.type;
|
||
|
|
const id = parseInt(item.dataset.id);
|
||
|
|
const path = item.dataset.path;
|
||
|
|
const name = item.dataset.name;
|
||
|
|
|
||
|
|
// Only allow selecting files in select mode
|
||
|
|
if (this.options.selectMode && type === "folder") {
|
||
|
|
// Navigate into folder instead
|
||
|
|
this.currentFolder = id;
|
||
|
|
this.loadContent();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const existingIndex = this.selectedItems.findIndex(
|
||
|
|
(s) => s.type === type && s.id === id,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (existingIndex >= 0) {
|
||
|
|
this.selectedItems.splice(existingIndex, 1);
|
||
|
|
item.classList.remove("selected");
|
||
|
|
item.querySelector(".item-checkbox input").checked = false;
|
||
|
|
} else {
|
||
|
|
if (!this.options.multiple && this.options.selectMode) {
|
||
|
|
// Single select mode - clear previous selection
|
||
|
|
this.selectedItems = [];
|
||
|
|
document.querySelectorAll(".media-item.selected").forEach((el) => {
|
||
|
|
el.classList.remove("selected");
|
||
|
|
el.querySelector(".item-checkbox input").checked = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
this.selectedItems.push({ type, id, path, name });
|
||
|
|
item.classList.add("selected");
|
||
|
|
item.querySelector(".item-checkbox input").checked = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.updateSelectedCount();
|
||
|
|
}
|
||
|
|
|
||
|
|
updateSelectedCount() {
|
||
|
|
const count = this.selectedItems.filter((s) => s.type === "file").length;
|
||
|
|
const countEl = document.getElementById("selectedCount");
|
||
|
|
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||
|
|
const selectBtn = document.getElementById("selectFilesBtn");
|
||
|
|
|
||
|
|
if (count > 0) {
|
||
|
|
countEl.style.display = "inline-flex";
|
||
|
|
countEl.querySelector(".count").textContent = count;
|
||
|
|
deleteBtn.style.display = "inline-flex";
|
||
|
|
if (this.options.selectMode) {
|
||
|
|
selectBtn.disabled = false;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
countEl.style.display = "none";
|
||
|
|
deleteBtn.style.display = "none";
|
||
|
|
if (this.options.selectMode) {
|
||
|
|
selectBtn.disabled = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sortFiles(files) {
|
||
|
|
const sorted = [...files];
|
||
|
|
|
||
|
|
switch (this.sortBy) {
|
||
|
|
case "date":
|
||
|
|
sorted.sort((a, b) => new Date(b.uploadDate) - new Date(a.uploadDate));
|
||
|
|
break;
|
||
|
|
case "date-asc":
|
||
|
|
sorted.sort((a, b) => new Date(a.uploadDate) - new Date(b.uploadDate));
|
||
|
|
break;
|
||
|
|
case "name":
|
||
|
|
sorted.sort((a, b) => a.originalName.localeCompare(b.originalName));
|
||
|
|
break;
|
||
|
|
case "name-desc":
|
||
|
|
sorted.sort((a, b) => b.originalName.localeCompare(a.originalName));
|
||
|
|
break;
|
||
|
|
case "size":
|
||
|
|
sorted.sort((a, b) => b.size - a.size);
|
||
|
|
break;
|
||
|
|
case "size-asc":
|
||
|
|
sorted.sort((a, b) => a.size - b.size);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
return sorted;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateBreadcrumb() {
|
||
|
|
const breadcrumb = document.getElementById("folderBreadcrumb");
|
||
|
|
const backBtn = document.getElementById("backBtn");
|
||
|
|
let html = `<li class="breadcrumb-item"><a href="#" data-folder="null">Root</a></li>`;
|
||
|
|
|
||
|
|
// Show/hide back button based on current folder
|
||
|
|
if (this.currentFolder) {
|
||
|
|
backBtn.style.display = "inline-flex";
|
||
|
|
} else {
|
||
|
|
backBtn.style.display = "none";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.currentFolder) {
|
||
|
|
const folder = this.folders.find((f) => f.id === this.currentFolder);
|
||
|
|
if (folder) {
|
||
|
|
const pathParts = folder.path.split("/").filter((p) => p);
|
||
|
|
let currentPath = "";
|
||
|
|
|
||
|
|
pathParts.forEach((part, index) => {
|
||
|
|
currentPath += "/" + part;
|
||
|
|
const pathFolder = this.folders.find((f) => f.path === currentPath);
|
||
|
|
const isLast = index === pathParts.length - 1;
|
||
|
|
|
||
|
|
if (isLast) {
|
||
|
|
html += `<li class="breadcrumb-item active">${this.escapeHtml(
|
||
|
|
part,
|
||
|
|
)}</li>`;
|
||
|
|
} else if (pathFolder) {
|
||
|
|
html += `<li class="breadcrumb-item"><a href="#" data-folder="${
|
||
|
|
pathFolder.id
|
||
|
|
}">${this.escapeHtml(part)}</a></li>`;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
breadcrumb.innerHTML = html;
|
||
|
|
|
||
|
|
// Bind breadcrumb clicks
|
||
|
|
breadcrumb.querySelectorAll("a").forEach((link) => {
|
||
|
|
link.addEventListener("click", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
const folderId = link.dataset.folder;
|
||
|
|
this.currentFolder = folderId === "null" ? null : parseInt(folderId);
|
||
|
|
this.loadContent();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
navigateBack() {
|
||
|
|
if (!this.currentFolder) return;
|
||
|
|
|
||
|
|
// Find the parent folder
|
||
|
|
const currentFolderObj = this.folders.find(
|
||
|
|
(f) => f.id === this.currentFolder,
|
||
|
|
);
|
||
|
|
if (currentFolderObj && currentFolderObj.parentId) {
|
||
|
|
this.currentFolder = currentFolderObj.parentId;
|
||
|
|
} else {
|
||
|
|
this.currentFolder = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.loadContent();
|
||
|
|
}
|
||
|
|
|
||
|
|
async uploadFiles(fileList) {
|
||
|
|
const files = Array.from(fileList);
|
||
|
|
const progressEl = document.getElementById("uploadProgress");
|
||
|
|
const progressBar = progressEl.querySelector(".progress-bar");
|
||
|
|
const progressPercent = progressEl.querySelector(".progress-percent");
|
||
|
|
|
||
|
|
progressEl.style.display = "block";
|
||
|
|
progressBar.style.width = "0%";
|
||
|
|
|
||
|
|
const formData = new FormData();
|
||
|
|
files.forEach((file) => formData.append("files", file));
|
||
|
|
if (this.currentFolder) {
|
||
|
|
formData.append("folder_id", this.currentFolder);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/admin/upload", {
|
||
|
|
method: "POST",
|
||
|
|
body: formData,
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
progressBar.style.width = "100%";
|
||
|
|
progressPercent.textContent = "100%";
|
||
|
|
this.showNotification(
|
||
|
|
`${data.files.length} file(s) uploaded successfully`,
|
||
|
|
"success",
|
||
|
|
);
|
||
|
|
await this.loadContent();
|
||
|
|
} else {
|
||
|
|
throw new Error(data.message || "Upload failed");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Upload error:", error);
|
||
|
|
this.showNotification(error.message || "Upload failed", "error");
|
||
|
|
} finally {
|
||
|
|
setTimeout(() => {
|
||
|
|
progressEl.style.display = "none";
|
||
|
|
progressBar.style.width = "0%";
|
||
|
|
}, 1000);
|
||
|
|
|
||
|
|
document.getElementById("fileInput").value = "";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async createFolder() {
|
||
|
|
const input = document.getElementById("newFolderInput");
|
||
|
|
const name = input.value.trim();
|
||
|
|
|
||
|
|
if (!name) {
|
||
|
|
this.showNotification("Please enter a folder name", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/admin/folders", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({
|
||
|
|
name: name,
|
||
|
|
parent_id: this.currentFolder,
|
||
|
|
}),
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
bootstrap.Modal.getInstance(
|
||
|
|
document.getElementById("createFolderModal"),
|
||
|
|
).hide();
|
||
|
|
this.showNotification("Folder created successfully", "success");
|
||
|
|
await this.loadContent();
|
||
|
|
} else {
|
||
|
|
throw new Error(data.error || "Failed to create folder");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Create folder error:", error);
|
||
|
|
this.showNotification(
|
||
|
|
error.message || "Failed to create folder",
|
||
|
|
"error",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
showRenameModal(id, type, currentName) {
|
||
|
|
document.getElementById("renameItemId").value = id;
|
||
|
|
document.getElementById("renameItemType").value = type;
|
||
|
|
|
||
|
|
// Remove extension for files
|
||
|
|
let displayName = currentName;
|
||
|
|
if (type === "file") {
|
||
|
|
const lastDot = currentName.lastIndexOf(".");
|
||
|
|
if (lastDot > 0) {
|
||
|
|
displayName = currentName.substring(0, lastDot);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById("renameInput").value = displayName;
|
||
|
|
new bootstrap.Modal(document.getElementById("renameModal")).show();
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
document.getElementById("renameInput").select();
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
|
||
|
|
async confirmRename() {
|
||
|
|
const id = parseInt(document.getElementById("renameItemId").value);
|
||
|
|
const type = document.getElementById("renameItemType").value;
|
||
|
|
const newName = document.getElementById("renameInput").value.trim();
|
||
|
|
|
||
|
|
if (!newName) {
|
||
|
|
this.showNotification("Please enter a name", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const endpoint =
|
||
|
|
type === "folder"
|
||
|
|
? `/api/admin/folders/${id}/rename`
|
||
|
|
: `/api/admin/uploads/${id}/rename`;
|
||
|
|
|
||
|
|
const response = await fetch(endpoint, {
|
||
|
|
method: "PATCH",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ newName }),
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
bootstrap.Modal.getInstance(
|
||
|
|
document.getElementById("renameModal"),
|
||
|
|
).hide();
|
||
|
|
this.showNotification("Renamed successfully", "success");
|
||
|
|
await this.loadContent();
|
||
|
|
} else {
|
||
|
|
throw new Error(data.error || "Failed to rename");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Rename error:", error);
|
||
|
|
this.showNotification(error.message || "Failed to rename", "error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async deleteItem(id, type, name) {
|
||
|
|
const self = this;
|
||
|
|
showDeleteConfirm(
|
||
|
|
`Are you sure you want to delete "${name}"?`,
|
||
|
|
async () => {
|
||
|
|
try {
|
||
|
|
const endpoint =
|
||
|
|
type === "folder"
|
||
|
|
? `/api/admin/folders/${id}?delete_contents=true`
|
||
|
|
: `/api/admin/uploads/id/${id}`;
|
||
|
|
|
||
|
|
const response = await fetch(endpoint, {
|
||
|
|
method: "DELETE",
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
self.showNotification("Deleted successfully", "success");
|
||
|
|
|
||
|
|
// Remove from selected items
|
||
|
|
self.selectedItems = self.selectedItems.filter(
|
||
|
|
(s) => !(s.type === type && s.id === id),
|
||
|
|
);
|
||
|
|
self.updateSelectedCount();
|
||
|
|
|
||
|
|
await self.loadContent();
|
||
|
|
} else {
|
||
|
|
throw new Error(data.error || "Failed to delete");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Delete error:", error);
|
||
|
|
self.showNotification(error.message || "Failed to delete", "error");
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ title: "Delete Item", confirmText: "Delete" },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async deleteSelected() {
|
||
|
|
const fileIds = this.selectedItems
|
||
|
|
.filter((s) => s.type === "file")
|
||
|
|
.map((s) => s.id);
|
||
|
|
const folderIds = this.selectedItems
|
||
|
|
.filter((s) => s.type === "folder")
|
||
|
|
.map((s) => s.id);
|
||
|
|
|
||
|
|
if (fileIds.length === 0 && folderIds.length === 0) return;
|
||
|
|
|
||
|
|
const total = fileIds.length + folderIds.length;
|
||
|
|
const self = this;
|
||
|
|
|
||
|
|
showDeleteConfirm(
|
||
|
|
`Are you sure you want to delete ${total} item(s)?`,
|
||
|
|
async () => {
|
||
|
|
try {
|
||
|
|
// Delete folders first
|
||
|
|
for (const folderId of folderIds) {
|
||
|
|
await fetch(`/api/admin/folders/${folderId}?delete_contents=true`, {
|
||
|
|
method: "DELETE",
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete files
|
||
|
|
if (fileIds.length > 0) {
|
||
|
|
await fetch("/api/admin/uploads/bulk-delete", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ file_ids: fileIds }),
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
self.showNotification(`${total} item(s) deleted`, "success");
|
||
|
|
self.selectedItems = [];
|
||
|
|
self.updateSelectedCount();
|
||
|
|
await self.loadContent();
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Bulk delete error:", error);
|
||
|
|
self.showNotification("Failed to delete some items", "error");
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ title: "Delete Items", confirmText: `Delete ${total} Items` },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async moveFileToFolder(fileId, folderId) {
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/admin/uploads/move", {
|
||
|
|
method: "PATCH",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({
|
||
|
|
file_ids: [fileId],
|
||
|
|
folder_id: folderId,
|
||
|
|
}),
|
||
|
|
credentials: "include",
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
this.showNotification("File moved successfully", "success");
|
||
|
|
await this.loadContent();
|
||
|
|
} else {
|
||
|
|
throw new Error(data.error || "Failed to move file");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Move error:", error);
|
||
|
|
this.showNotification(error.message || "Failed to move file", "error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
confirmSelection() {
|
||
|
|
const selectedFiles = this.selectedItems.filter((s) => s.type === "file");
|
||
|
|
|
||
|
|
if (selectedFiles.length === 0) {
|
||
|
|
this.showNotification("Please select at least one file", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.options.onSelect) {
|
||
|
|
const result = this.options.multiple ? selectedFiles : selectedFiles[0];
|
||
|
|
this.options.onSelect(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.close();
|
||
|
|
}
|
||
|
|
|
||
|
|
showPreview(path, name, size) {
|
||
|
|
const overlay = document.getElementById("imagePreviewOverlay");
|
||
|
|
document.getElementById("previewImage").src = path;
|
||
|
|
document.getElementById("previewFilename").textContent = name;
|
||
|
|
document.getElementById("previewSize").textContent = this.formatFileSize(
|
||
|
|
parseInt(size),
|
||
|
|
);
|
||
|
|
overlay.classList.add("active");
|
||
|
|
document.body.style.overflow = "hidden";
|
||
|
|
}
|
||
|
|
|
||
|
|
closePreview() {
|
||
|
|
const overlay = document.getElementById("imagePreviewOverlay");
|
||
|
|
overlay.classList.remove("active");
|
||
|
|
document.body.style.overflow = "";
|
||
|
|
}
|
||
|
|
|
||
|
|
showNotification(message, type = "info") {
|
||
|
|
// Create toast notification
|
||
|
|
const toast = document.createElement("div");
|
||
|
|
toast.className = `media-toast ${type}`;
|
||
|
|
toast.innerHTML = `
|
||
|
|
<i class="bi bi-${
|
||
|
|
type === "success"
|
||
|
|
? "check-circle"
|
||
|
|
: type === "error"
|
||
|
|
? "exclamation-circle"
|
||
|
|
: "info-circle"
|
||
|
|
}"></i>
|
||
|
|
<span>${this.escapeHtml(message)}</span>
|
||
|
|
`;
|
||
|
|
|
||
|
|
document.body.appendChild(toast);
|
||
|
|
|
||
|
|
setTimeout(() => toast.classList.add("show"), 10);
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
toast.classList.remove("show");
|
||
|
|
setTimeout(() => toast.remove(), 300);
|
||
|
|
}, 3000);
|
||
|
|
}
|
||
|
|
|
||
|
|
formatFileSize(bytes) {
|
||
|
|
if (bytes === 0) return "0 Bytes";
|
||
|
|
const k = 1024;
|
||
|
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||
|
|
}
|
||
|
|
|
||
|
|
isVideoFile(filename) {
|
||
|
|
if (!filename) return false;
|
||
|
|
const ext = filename.toLowerCase().split(".").pop();
|
||
|
|
return ["mp4", "webm", "mov", "avi", "mkv"].includes(ext);
|
||
|
|
}
|
||
|
|
|
||
|
|
escapeHtml(text) {
|
||
|
|
const div = document.createElement("div");
|
||
|
|
div.textContent = text;
|
||
|
|
return div.innerHTML;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Export for use in other files
|
||
|
|
window.MediaLibrary = MediaLibrary;
|