webupdate

This commit is contained in:
Local Server
2026-01-20 20:29:33 -06:00
parent f8068ba54c
commit 1b2502c38d
22 changed files with 1905 additions and 172 deletions

View File

@@ -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";
}
}