webupdate
This commit is contained in:
@@ -69,6 +69,126 @@
|
||||
.stat-item i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Skeleton Loading Animation */
|
||||
.skeleton-item {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f0f0f0 25%,
|
||||
#e0e0e0 50%,
|
||||
#f0f0f0 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-name {
|
||||
height: 14px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f0f0f0 25%,
|
||||
#e0e0e0 50%,
|
||||
#f0f0f0 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-meta {
|
||||
height: 12px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f0f0f0 25%,
|
||||
#e0e0e0 50%,
|
||||
#f0f0f0 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lazy loading image placeholder - instant load, no delay */
|
||||
.media-item .item-preview img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-item .item-preview img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-item .item-preview {
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Loading indicator at bottom */
|
||||
.load-more-indicator {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.load-more-indicator.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fixed toolbar styles when scrolling */
|
||||
.media-library-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.media-library-toolbar {
|
||||
background: white;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.media-library-toolbar.fixed-toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0 !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Placeholder to prevent content jump when toolbar becomes fixed */
|
||||
.toolbar-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-placeholder.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,6 +282,14 @@
|
||||
>
|
||||
<i class="bi bi-folder-plus"></i> New Folder
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
id="deleteSelectedBtnTop"
|
||||
style="display: none"
|
||||
>
|
||||
<i class="bi bi-trash"></i> Delete Selected
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
<div class="search-box">
|
||||
<i class="bi bi-search"></i>
|
||||
@@ -430,14 +558,105 @@
|
||||
let sortBy = localStorage.getItem("mediaLibrarySort") || "date";
|
||||
let searchQuery = "";
|
||||
let draggedItem = null;
|
||||
let isLoading = false;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initViewMode();
|
||||
showSkeletonLoading();
|
||||
loadContent();
|
||||
bindEvents();
|
||||
setupStickyToolbar();
|
||||
});
|
||||
|
||||
function setupStickyToolbar() {
|
||||
const toolbar = document.querySelector(".media-library-toolbar");
|
||||
const mediaLibraryPage = document.querySelector(".media-library-page");
|
||||
|
||||
if (!toolbar || !mediaLibraryPage) return;
|
||||
|
||||
// Create placeholder element to prevent content jump
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "toolbar-placeholder";
|
||||
toolbar.parentNode.insertBefore(placeholder, toolbar.nextSibling);
|
||||
|
||||
// Store original toolbar dimensions
|
||||
const toolbarHeight = toolbar.offsetHeight;
|
||||
placeholder.style.height = toolbarHeight + "px";
|
||||
|
||||
// Get the sidebar width for proper left offset
|
||||
const sidebarWidth = 250; // matches --sidebar-width in CSS
|
||||
const mainContentPadding = 30; // matches main-content padding
|
||||
|
||||
function handleScroll() {
|
||||
const pageRect = mediaLibraryPage.getBoundingClientRect();
|
||||
const pageTop = pageRect.top;
|
||||
|
||||
// When the media-library-page scrolls past the top, fix the toolbar
|
||||
if (pageTop <= 0) {
|
||||
if (!toolbar.classList.contains("fixed-toolbar")) {
|
||||
toolbar.classList.add("fixed-toolbar");
|
||||
placeholder.classList.add("visible");
|
||||
// Set the width and left position
|
||||
toolbar.style.left = sidebarWidth + mainContentPadding + "px";
|
||||
toolbar.style.right = mainContentPadding + "px";
|
||||
}
|
||||
} else {
|
||||
if (toolbar.classList.contains("fixed-toolbar")) {
|
||||
toolbar.classList.remove("fixed-toolbar");
|
||||
placeholder.classList.remove("visible");
|
||||
toolbar.style.left = "";
|
||||
toolbar.style.right = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
// Initial check
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
function showSkeletonLoading() {
|
||||
const content = document.getElementById("mediaContent");
|
||||
let skeletonHtml = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
skeletonHtml += `
|
||||
<div class="media-item skeleton-item">
|
||||
<div class="skeleton-preview"></div>
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-name"></div>
|
||||
<div class="skeleton-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = skeletonHtml;
|
||||
}
|
||||
|
||||
function getFilteredFolders() {
|
||||
let filteredFolders = folders.filter((f) =>
|
||||
currentFolder ? f.parentId === currentFolder : f.parentId === null,
|
||||
);
|
||||
if (searchQuery) {
|
||||
filteredFolders = filteredFolders.filter((f) =>
|
||||
f.name.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
}
|
||||
return filteredFolders;
|
||||
}
|
||||
|
||||
function getFilteredFiles() {
|
||||
let filteredFiles = files;
|
||||
if (searchQuery) {
|
||||
filteredFiles = filteredFiles.filter(
|
||||
(f) =>
|
||||
f.originalName.toLowerCase().includes(searchQuery) ||
|
||||
f.filename.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
}
|
||||
return filteredFiles;
|
||||
}
|
||||
|
||||
function initViewMode() {
|
||||
const content = document.getElementById("mediaContent");
|
||||
content.classList.remove("grid-view", "list-view");
|
||||
@@ -538,11 +757,16 @@
|
||||
if (e.key === "Enter") confirmRename();
|
||||
});
|
||||
|
||||
// Delete selected
|
||||
// Delete selected (bottom button)
|
||||
document
|
||||
.getElementById("deleteSelectedBtn")
|
||||
.addEventListener("click", deleteSelected);
|
||||
|
||||
// Delete selected (top button)
|
||||
document
|
||||
.getElementById("deleteSelectedBtnTop")
|
||||
.addEventListener("click", deleteSelected);
|
||||
|
||||
// Back button
|
||||
document
|
||||
.getElementById("backBtn")
|
||||
@@ -569,7 +793,11 @@
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
isLoading = true;
|
||||
displayedCount = 0;
|
||||
|
||||
try {
|
||||
// Load folders and files in parallel
|
||||
const [foldersRes, filesRes] = await Promise.all([
|
||||
fetch("/api/admin/folders", { credentials: "include" }),
|
||||
fetch(
|
||||
@@ -588,22 +816,34 @@
|
||||
folders = foldersData.folders || [];
|
||||
files = filesData.files || [];
|
||||
|
||||
updateStats();
|
||||
// Render content immediately (first batch)
|
||||
renderContent();
|
||||
updateBreadcrumb();
|
||||
|
||||
// Update stats in background (non-blocking)
|
||||
updateStatsAsync();
|
||||
} catch (error) {
|
||||
console.error("Error loading media:", error);
|
||||
showToast("Failed to load media", "error");
|
||||
document.getElementById("mediaContent").innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<h5>Failed to load media</h5>
|
||||
<p>Please try refreshing the page</p>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
// Count all folders
|
||||
function updateStatsAsync() {
|
||||
// Update folder count immediately from current data
|
||||
document.getElementById("folderCount").textContent = `${
|
||||
folders.length
|
||||
} folder${folders.length !== 1 ? "s" : ""}`;
|
||||
|
||||
// Need to get total file count
|
||||
// Fetch total file stats in background
|
||||
fetch("/api/admin/uploads", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
@@ -618,32 +858,18 @@
|
||||
);
|
||||
document.getElementById("totalSize").textContent =
|
||||
formatFileSize(totalBytes) + " used";
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - stats are not critical
|
||||
});
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const content = document.getElementById("mediaContent");
|
||||
|
||||
// Filter folders for current directory
|
||||
let filteredFolders = folders.filter((f) =>
|
||||
currentFolder ? f.parentId === currentFolder : f.parentId === null,
|
||||
);
|
||||
let filteredFiles = files;
|
||||
|
||||
// Apply search
|
||||
if (searchQuery) {
|
||||
filteredFolders = filteredFolders.filter((f) =>
|
||||
f.name.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
filteredFiles = filteredFiles.filter(
|
||||
(f) =>
|
||||
f.originalName.toLowerCase().includes(searchQuery) ||
|
||||
f.filename.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort files
|
||||
filteredFiles = sortFiles(filteredFiles);
|
||||
// Get filtered content
|
||||
const filteredFolders = getFilteredFolders();
|
||||
const filteredFiles = sortFiles(getFilteredFiles());
|
||||
|
||||
if (filteredFolders.length === 0 && filteredFiles.length === 0) {
|
||||
content.innerHTML = `
|
||||
@@ -671,9 +897,7 @@
|
||||
);
|
||||
html += `
|
||||
<div class="media-item folder-item ${isSelected ? "selected" : ""}"
|
||||
data-type="folder" data-id="${
|
||||
folder.id
|
||||
}" data-name="${escapeHtml(folder.name)}"
|
||||
data-type="folder" data-id="${folder.id}" data-name="${escapeHtml(folder.name)}"
|
||||
draggable="true">
|
||||
<div class="item-checkbox">
|
||||
<input type="checkbox" ${isSelected ? "checked" : ""}>
|
||||
@@ -697,7 +921,7 @@
|
||||
`;
|
||||
});
|
||||
|
||||
// Files
|
||||
// Files - use data-src for lazy loading
|
||||
filteredFiles.forEach((file) => {
|
||||
const isSelected = selectedItems.some(
|
||||
(s) => s.type === "file" && s.id === file.id,
|
||||
@@ -705,22 +929,17 @@
|
||||
html += `
|
||||
<div class="media-item file-item ${isSelected ? "selected" : ""}"
|
||||
data-type="file" data-id="${file.id}" data-path="${file.path}"
|
||||
data-name="${escapeHtml(file.originalName)}" data-size="${
|
||||
file.size
|
||||
}"
|
||||
data-name="${escapeHtml(file.originalName)}" data-size="${file.size}"
|
||||
draggable="true">
|
||||
<div class="item-checkbox">
|
||||
<input type="checkbox" ${isSelected ? "checked" : ""}>
|
||||
</div>
|
||||
<div class="item-preview">
|
||||
<img src="${file.path}" alt="${escapeHtml(
|
||||
file.originalName,
|
||||
)}" loading="lazy">
|
||||
<img data-src="${file.path}" alt="${escapeHtml(file.originalName)}"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=">
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<span class="item-name" title="${escapeHtml(
|
||||
file.originalName,
|
||||
)}">${escapeHtml(file.originalName)}</span>
|
||||
<span class="item-name" title="${escapeHtml(file.originalName)}">${escapeHtml(file.originalName)}</span>
|
||||
<span class="item-meta">${formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
@@ -740,6 +959,61 @@
|
||||
|
||||
content.innerHTML = html;
|
||||
bindItemEvents();
|
||||
|
||||
// Setup lazy loading for images
|
||||
setupLazyLoading();
|
||||
}
|
||||
|
||||
function setupLazyLoading() {
|
||||
const PRELOAD_COUNT = 50; // Preload first 50 images immediately
|
||||
const images = document.querySelectorAll(".item-preview img[data-src]");
|
||||
|
||||
// Preload first 50 images immediately
|
||||
images.forEach((img, index) => {
|
||||
if (index < PRELOAD_COUNT) {
|
||||
const src = img.dataset.src;
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.classList.add("loaded");
|
||||
img.onerror = () => {
|
||||
img.src =
|
||||
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="%23999" font-size="12">No preview</text></svg>';
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Use Intersection Observer for lazy loading remaining images with large margin
|
||||
const imageObserver = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const src = img.dataset.src;
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.classList.add("loaded");
|
||||
img.onerror = () => {
|
||||
img.src =
|
||||
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="%23999" font-size="12">No preview</text></svg>';
|
||||
};
|
||||
observer.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: "500px", // Load images 500px before they appear
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Only observe images after the first 50
|
||||
images.forEach((img, index) => {
|
||||
if (index >= PRELOAD_COUNT && !img.classList.contains("loaded")) {
|
||||
imageObserver.observe(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindItemEvents() {
|
||||
@@ -859,14 +1133,17 @@
|
||||
const count = selectedItems.length;
|
||||
const countEl = document.getElementById("selectedCount");
|
||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||
const deleteBtnTop = document.getElementById("deleteSelectedBtnTop");
|
||||
|
||||
if (count > 0) {
|
||||
countEl.style.display = "inline-flex";
|
||||
countEl.querySelector(".count").textContent = count;
|
||||
deleteBtn.style.display = "inline-flex";
|
||||
deleteBtnTop.style.display = "inline-flex";
|
||||
} else {
|
||||
countEl.style.display = "none";
|
||||
deleteBtn.style.display = "none";
|
||||
deleteBtnTop.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user