diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d17cf9..87d42f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## Changes 12/3/2025 (v2.3.1)
+
+release(v2.3.1): polish file list actions & hover preview peak
+
+- Replace per-row action button stack with compact 3-dot “More actions” menu in file list and folder tree
+- Add desktop hover preview peak card for files & folders (image thumb, text snippet, quick metadata)
+- Add per-user toggle to disable file hover preview (stored in localStorage)
+- Improve preview overlay: add Download button, Zoom/Rotate labels, keep download target in sync when navigating images/videos
+- Fix mobile table layout so Size column is visible for files & folders
+- Tweak dark/light glassmorphism styles for hover card and action buttons
+- Clean up size parsing and editable flag logic for big/unknown files
+
+---
+
## Changes 12/2/2025 (v2.3.0)
release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export
diff --git a/public/css/styles.css b/public/css/styles.css
index 74bef69..c60f59d 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1475,7 +1475,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
.dark-mode .card{background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;}
-.card-header{font-size: 1.2rem;
+.card-header{font-size: 1.1rem;
font-weight: bold;}
.custom-folder-card-body{padding-top: 5px !important;
padding-right: 0 !important;
@@ -2560,4 +2560,368 @@ body.dark-mode .portal-submissions-block .portal-submissions-load-btn {
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:hover,
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:focus-visible {
background: rgba(255, 255, 255, 0.10);
+}
+/* ============================================
+ TABLE ACTIONS: 3-dot header + row buttons
+ ============================================ */
+
+/* Compact "Actions" column */
+th[data-column="actions"],
+td.actions-cell,
+td.folder-actions-cell {
+ width: 40px;
+ max-width: 40px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+/* Hide "Actions" text but keep it for screen readers */
+th[data-column="actions"] {
+ position: relative;
+ text-indent: -9999px;
+}
+
+/* Show a 3-dot Material icon in the header instead */
+th[data-column="actions"]::after {
+ content: "more_horiz";
+ font-family: "Material Icons";
+ text-indent: 0;
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ color: #6b7280;
+}
+
+.dark-mode th[data-column="actions"]::after,
+[data-theme="dark"] th[data-column="actions"]::after {
+ color: #9ca3af;
+}
+
+/* Row-level 3-dot button */
+.btn-actions-ellipsis {
+ border: none;
+ background: transparent;
+ padding: 0;
+ line-height: 1;
+ box-shadow: none;
+ border-radius: 999px;
+ width: 26px;
+ height: 26px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition:
+ background-color 0.16s ease-out,
+ box-shadow 0.16s ease-out,
+ transform 0.12s ease-out;
+}
+
+.btn-actions-ellipsis .material-icons {
+ font-size: 20px;
+ color: var(--filr-icon-muted, #6b7280);
+}
+
+/* Dark theme icon color */
+.dark-mode .btn-actions-ellipsis .material-icons,
+[data-theme="dark"] .btn-actions-ellipsis .material-icons {
+ color: #e5e7eb;
+}
+
+/* Glassy hover for 3-dot trigger (light) */
+.btn-actions-ellipsis:hover,
+.btn-actions-ellipsis:focus-visible {
+ outline: none;
+ background-color: rgba(148, 163, 184, 0.18);
+ box-shadow:
+ 0 0 0 1px rgba(148, 163, 184, 0.4),
+ 0 6px 14px rgba(15, 23, 42, 0.22);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ transform: translateY(-1px);
+}
+
+/* Glassy hover for 3-dot trigger (dark) */
+.dark-mode .btn-actions-ellipsis:hover,
+.dark-mode .btn-actions-ellipsis:focus-visible,
+[data-theme="dark"] .btn-actions-ellipsis:hover,
+[data-theme="dark"] .btn-actions-ellipsis:focus-visible {
+ background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
+ box-shadow:
+ 0 0 0 1px var(--fr-border-dark),
+ 0 10px 24px rgba(0, 0, 0, 0.7);
+}
+.btn-actions-ellipsis.btn-link,
+.btn-actions-ellipsis.btn-link:hover,
+.btn-actions-ellipsis.btn-link:focus,
+.btn-actions-ellipsis.btn-link:focus-visible {
+ text-decoration: none !important;
+}
+
+/* ============================================
+ HOVER PREVIEW CARD – glassmorphism
+ ============================================ */
+/* Clickable glass hover card */
+#hoverPreview {
+ pointer-events: auto;
+}
+
+/* === DARK THEME GLASS CARD (no banding) ======================= */
+.hover-preview-card {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ min-width: 420px;
+ max-width: 640px;
+ min-height: 220px;
+ padding: 10px 12px;
+ border-radius: 14px;
+ overflow: hidden;
+
+ /* Base: semi-opaque dark, no banding */
+ background-color: color-mix(
+ in srgb,
+ var(--fr-surface-dark, #0f172a) 78%,
+ transparent
+ ) !important;
+
+ /* Very subtle linear sheen (small contrast = no visible bands) */
+ background-image: linear-gradient(
+ 135deg,
+ rgba(255, 255, 255, 0.06),
+ rgba(255, 255, 255, 0.0)
+ );
+
+ border: 1px solid color-mix(
+ in srgb,
+ var(--fr-border-dark, #1f2937) 70%,
+ transparent
+ );
+
+ box-shadow:
+ 0 18px 40px rgba(0, 0, 0, 0.55),
+ 0 0 0 1px rgba(0, 0, 0, 0.35);
+
+ color: #e5e7eb;
+ font-size: 12px;
+
+ /* Glass feel: blur + mild saturation */
+ backdrop-filter: blur(18px) saturate(135%);
+ -webkit-backdrop-filter: blur(18px) saturate(135%);
+}
+
+/* === LIGHT THEME GLASS CARD =================================== */
+[data-theme="light"] .hover-preview-card {
+ background-color: rgba(255, 255, 255, 0.86) !important;
+ background-image: linear-gradient(
+ 135deg,
+ rgba(255, 255, 255, 0.98),
+ rgba(249, 250, 251, 0.80)
+ );
+
+ border-color: rgba(148, 163, 184, 0.45);
+ box-shadow:
+ 0 16px 32px rgba(15, 23, 42, 0.16),
+ 0 0 0 1px rgba(255, 255, 255, 0.9);
+
+ color: #111827;
+
+ backdrop-filter: blur(16px) saturate(130%);
+ -webkit-backdrop-filter: blur(16px) saturate(130%);
+}
+
+/* Two-column inner layout */
+.hover-preview-grid {
+ display: grid;
+ grid-template-columns: 220px minmax(260px, 1fr);
+ gap: 12px;
+ align-items: center; /* center LEFT + RIGHT in the same row */
+ width: 100%;
+}
+
+/* Left column: image + snippet */
+.hover-preview-left {
+ display: flex;
+ flex-direction: column;
+ justify-content: center; /* center inside its own grid cell */
+ min-width: 0;
+}
+
+/* Right column: title + meta + props */
+.hover-preview-right {
+ display: flex;
+ flex-direction: column;
+ justify-content: center; /* center inside its own grid cell */
+ min-width: 0;
+ overflow: hidden;
+}
+
+/* Thumb area */
+.hover-preview-thumb {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 140px;
+ margin-bottom: 6px;
+}
+
+/* Text / folder peek snippet block */
+.hover-preview-snippet {
+ margin-top: 4px;
+ max-height: 140px;
+ overflow: auto;
+ font-size: 0.78rem;
+ white-space: pre-wrap;
+ padding: 6px 8px;
+ border-radius: 6px;
+
+ /* Dark chip so it always has contrast vs the card */
+ background-color: rgba(39, 39, 39, 0.92) !important;
+ color: #e5e7eb !important;
+}
+
+/* You can keep this same in light mode (still looks good), or tweak slightly */
+[data-theme="light"] .hover-preview-snippet {
+ background-color: rgba(39, 39, 39, 0.92) !important;
+ color: #f9fafb !important;
+}
+
+/* Title + meta + props */
+.hover-preview-title {
+ font-weight: 600;
+ font-size: 0.95rem;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+.hover-preview-meta {
+ font-size: 0.8rem;
+ opacity: 0.8;
+ margin-bottom: 6px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+[data-theme="light"] .hover-preview-meta {
+ color: #6b7280;
+}
+
+.hover-preview-props {
+ font-size: 0.78rem;
+ line-height: 1.3;
+ max-height: 160px;
+ overflow: auto;
+ padding-right: 4px;
+ word-break: break-word;
+}
+
+.hover-prop-line {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Icon color */
+.hover-preview-icon.material-icons {
+ font-size: 26px;
+ color: #93c5fd;
+}
+
+[data-theme="light"] .hover-preview-icon.material-icons {
+ color: #2563eb;
+}
+/* Row-level 3-dot button: shared between file list + folder tree */
+.btn-actions-ellipsis,
+.folder-kebab {
+ border: none;
+ background: transparent;
+ padding: 0;
+ line-height: 1;
+ box-shadow: none;
+ border-radius: 999px;
+ width: 26px;
+ height: 26px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition:
+ background-color 0.16s ease-out,
+ box-shadow 0.16s ease-out,
+ transform 0.12s ease-out;
+}
+
+/* Icon sizing + base color */
+.btn-actions-ellipsis .material-icons,
+.folder-kebab.material-icons {
+ font-size: 20px;
+ color: var(--filr-icon-muted, #6b7280);
+}
+
+/* Dark theme icon color */
+.dark-mode .btn-actions-ellipsis .material-icons,
+[data-theme="dark"] .btn-actions-ellipsis .material-icons,
+.dark-mode .folder-kebab.material-icons,
+[data-theme="dark"] .folder-kebab.material-icons {
+ color: #e5e7eb;
+}
+
+/* Glassy hover for 3-dot trigger (light) */
+.btn-actions-ellipsis:hover,
+.btn-actions-ellipsis:focus-visible,
+.folder-kebab:hover,
+.folder-kebab:focus-visible {
+ outline: none;
+ background-color: rgba(148, 163, 184, 0.18);
+ box-shadow:
+ 0 0 0 1px rgba(148, 163, 184, 0.4),
+ 0 6px 14px rgba(15, 23, 42, 0.22);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ transform: translateY(-1px);
+}
+
+/* Glassy hover for 3-dot trigger (dark) */
+.dark-mode .btn-actions-ellipsis:hover,
+.dark-mode .btn-actions-ellipsis:focus-visible,
+[data-theme="dark"] .btn-actions-ellipsis:hover,
+[data-theme="dark"] .btn-actions-ellipsis:focus-visible,
+.dark-mode .folder-kebab:hover,
+.dark-mode .folder-kebab:focus-visible,
+[data-theme="dark"] .folder-kebab:hover,
+[data-theme="dark"] .folder-kebab:focus-visible {
+ background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
+ box-shadow:
+ 0 0 0 1px var(--fr-border-dark),
+ 0 10px 24px rgba(0, 0, 0, 0.7);
+}
+
+/* Keep folder modals in DOM for JS, but hide the old toolbar icons */
+.folder-actions {
+ /* still exists so modals can be found + detached */
+ display: block;
+ position: relative;
+}
+
+/* Hide the icon buttons, keep their IDs for JS wiring */
+.folder-actions > button {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
}
\ No newline at end of file
diff --git a/public/js/authModals.js b/public/js/authModals.js
index 1978e0b..ed71ea8 100644
--- a/public/js/authModals.js
+++ b/public/js/authModals.js
@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
//window.__lastLoginData = data;
}
+function isHoverPreviewDisabled() {
+ if (window.disableHoverPreview === true) return true;
+ try {
+ return localStorage.getItem('disableHoverPreview') === 'true';
+ } catch {
+ return false;
+ }
+}
+
export function openTOTPLoginModal() {
let totpLoginModal = document.getElementById("totpLoginModal");
const isDarkMode = document.body.classList.contains("dark-mode");
@@ -454,6 +463,43 @@ export async function openUserPanel() {
}
});
+ // 4) Disable hover preview
+ const hoverLabel = document.createElement('label');
+ hoverLabel.style.cursor = 'pointer';
+ hoverLabel.style.display = 'block';
+ hoverLabel.style.marginTop = '4px';
+
+ const hoverCb = document.createElement('input');
+ hoverCb.type = 'checkbox';
+ hoverCb.id = 'disableHoverPreview';
+ hoverCb.style.verticalAlign = 'middle';
+
+ {
+ const storedHover = localStorage.getItem('disableHoverPreview');
+ hoverCb.checked = storedHover === 'true';
+ // also mirror into a global flag for runtime checks
+ window.disableHoverPreview = hoverCb.checked;
+ }
+
+ hoverLabel.appendChild(hoverCb);
+ hoverLabel.append(
+ ` ${t('disable_hover_preview') || 'Disable file hover preview'}`
+ );
+ dispFs.appendChild(hoverLabel);
+
+ // Handler: toggle hover preview
+ hoverCb.addEventListener('change', () => {
+ const disabled = hoverCb.checked;
+ localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
+ window.disableHoverPreview = disabled;
+
+ // Hide any currently-visible preview right away
+ const preview = document.getElementById('hoverPreview');
+ if (preview) {
+ preview.style.display = 'none';
+ }
+ });
+
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
@@ -524,6 +570,13 @@ export async function openUserPanel() {
}
}
+ const hoverCb = modal.querySelector('#disableHoverPreview');
+ if (hoverCb) {
+ const storedHover = localStorage.getItem('disableHoverPreview');
+ hoverCb.checked = storedHover === 'true';
+ window.disableHoverPreview = hoverCb.checked;
+ }
+
// show
modal.style.display = 'flex';
}
diff --git a/public/js/domUtils.js b/public/js/domUtils.js
index 2873b9a..5a0b4f7 100644
--- a/public/js/domUtils.js
+++ b/public/js/domUtils.js
@@ -163,9 +163,9 @@ export function buildFileTableHeader(sortOrder) {
${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
- ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
+ ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""} |
- ${t("actions")} |
+ ${t("actions")} |
`;
@@ -175,99 +175,32 @@ export function buildFileTableRow(file, folderPath) {
const safeFileName = escapeHTML(file.name);
const safeModified = escapeHTML(file.modified);
const safeUploaded = escapeHTML(file.uploaded);
- const safeSize = escapeHTML(file.size);
+ const safeSize = escapeHTML(file.size);
const safeUploader = escapeHTML(file.uploader || "Unknown");
- let previewButton = "";
-
- const isSvg = /\.svg$/i.test(file.name);
-
- // IMPORTANT: do NOT treat SVG as previewable
- if (
- !isSvg &&
- /\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
- .test(file.name)
- ) {
- let previewIcon = "";
-
- // images (SVG explicitly excluded)
- if (
- /\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
- .test(file.name)
- ) {
- previewIcon = `image`;
- } else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
- previewIcon = `videocam`;
- } else if (/\.pdf$/i.test(file.name)) {
- previewIcon = `picture_as_pdf`;
- } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
- previewIcon = `audiotrack`;
- }
-
- previewButton = `
- `;
- }
-
return `
-
- |
-
- |
- ${safeFileName} |
- ${safeModified} |
- ${safeUploaded} |
- ${safeSize} |
- ${safeUploader} |
-
-
-
-
- ${file.editable ? `
- ` : ""}
-
- ${previewButton}
-
-
-
- |
-`;
+
+
+ `;
}
export function buildBottomControls(itemsPerPageSetting) {
diff --git a/public/js/fileDragDrop.js b/public/js/fileDragDrop.js
index 574e112..af8a9cf 100644
--- a/public/js/fileDragDrop.js
+++ b/public/js/fileDragDrop.js
@@ -1,6 +1,6 @@
// fileDragDrop.js
import { showToast } from './domUtils.js?v={{APP_QVER}}';
-import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
+import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
/* ---------------- helpers ---------------- */
function getRowEl(el) {
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
/* ---------------- drag start (rows/cards) ---------------- */
export function fileDragStartHandler(event) {
+ try { cancelHoverPreview(); } catch {}
const row = getRowEl(event.currentTarget);
if (!row) return;
diff --git a/public/js/fileListView.js b/public/js/fileListView.js
index 4737bef..f7dc331 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -214,6 +214,308 @@ function repaintStripIcon(folder) {
const kind = iconSpan.dataset.kind || 'empty';
iconSpan.innerHTML = folderSVG(kind);
}
+const TEXT_PREVIEW_MAX_BYTES = 120 * 1024; // ~120 KB
+const _fileSnippetCache = new Map();
+
+function getFileExt(name) {
+ const dot = name.lastIndexOf(".");
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
+}
+
+async function fillFileSnippet(file, snippetEl) {
+ if (!snippetEl) return;
+ snippetEl.textContent = "";
+ snippetEl.style.display = "none";
+
+ const folder = file.folder || window.currentFolder || "root";
+ const key = `${folder}::${file.name}`;
+
+ if (!canEditFile(file.name)) {
+ // No text preview possible for this type – cache the fact and bail
+ _fileSnippetCache.set(key, "");
+ return;
+ }
+
+ const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
+ if (bytes != null && bytes > TEXT_PREVIEW_MAX_BYTES) {
+ // File is too large to safely preview inline
+ const msg = t("no_preview_available") || "No preview available";
+ snippetEl.style.display = "block";
+ snippetEl.textContent = msg;
+ _fileSnippetCache.set(key, msg);
+ return;
+ }
+
+ // Use cache if we have it
+ if (_fileSnippetCache.has(key)) {
+ const cached = _fileSnippetCache.get(key);
+ if (cached) {
+ snippetEl.textContent = cached;
+ snippetEl.style.display = "block";
+ }
+ return;
+ }
+
+ snippetEl.style.display = "block";
+ snippetEl.textContent = t("loading") || "Loading...";
+
+ try {
+ const url = apiFileUrl(folder, file.name, true);
+ const res = await fetch(url, { credentials: "include" });
+ if (!res.ok) throw 0;
+ const text = await res.text();
+
+ const MAX_LINES = 6;
+ const MAX_CHARS = 600;
+
+ const allLines = text.split(/\r?\n/);
+ let visibleLines = allLines.slice(0, MAX_LINES);
+ let snippet = visibleLines.join("\n");
+ let truncated = allLines.length > MAX_LINES;
+
+ if (snippet.length > MAX_CHARS) {
+ snippet = snippet.slice(0, MAX_CHARS);
+ truncated = true;
+ }
+
+ snippet = snippet.trim();
+ let finalSnippet = snippet || "(empty file)";
+ if (truncated) {
+ finalSnippet += "\n…";
+ }
+
+ _fileSnippetCache.set(key, finalSnippet);
+ snippetEl.textContent = finalSnippet;
+ } catch {
+ snippetEl.textContent = "";
+ snippetEl.style.display = "none";
+ _fileSnippetCache.set(key, "");
+ }
+}
+
+function wireEllipsisContextMenu(fileListContent) {
+ if (!fileListContent) return;
+
+ fileListContent
+ .querySelectorAll(".btn-actions-ellipsis")
+ .forEach(btn => {
+ btn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const row = btn.closest("tr");
+ if (!row) return;
+
+ const rect = btn.getBoundingClientRect();
+ const evt = new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ clientX: rect.left + rect.width / 2,
+ clientY: rect.bottom
+ });
+
+ row.dispatchEvent(evt);
+ });
+ });
+}
+
+let hoverPreviewEl = null;
+let hoverPreviewTimer = null;
+let hoverPreviewActiveRow = null;
+let hoverPreviewContext = null;
+let hoverPreviewHoveringCard = false;
+
+// Let other modules (drag/drop) kill the hover card instantly.
+export function cancelHoverPreview() {
+ try {
+ if (hoverPreviewTimer) {
+ clearTimeout(hoverPreviewTimer);
+ hoverPreviewTimer = null;
+ }
+ } catch {}
+
+ hoverPreviewActiveRow = null;
+ hoverPreviewContext = null;
+ hoverPreviewHoveringCard = false;
+
+ if (hoverPreviewEl) {
+ hoverPreviewEl.style.display = 'none';
+ }
+}
+
+function isHoverPreviewDisabled() {
+ // Live flag from user panel
+ if (window.disableHoverPreview === true) return true;
+
+ // Fallback to localStorage (e.g. on first page load)
+ try {
+ return localStorage.getItem('disableHoverPreview') === 'true';
+ } catch {
+ return false;
+ }
+}
+
+function ensureHoverPreviewEl() {
+ if (hoverPreviewEl) return hoverPreviewEl;
+
+ const el = document.createElement("div");
+ el.id = "hoverPreview";
+ el.style.position = "fixed";
+ el.style.zIndex = "9999";
+ el.style.display = "none";
+ el.innerHTML = `
+
+ `;
+ document.body.appendChild(el);
+ hoverPreviewEl = el;
+
+ // ---- Layout + sizing tweaks ---------------------------------
+ const card = el.querySelector(".hover-preview-card");
+ const grid = el.querySelector(".hover-preview-grid");
+ const leftCol = el.querySelector(".hover-preview-left");
+ const rightCol = el.querySelector(".hover-preview-right");
+ const thumb = el.querySelector(".hover-preview-thumb");
+ const snippet = el.querySelector(".hover-preview-snippet");
+ const titleEl = el.querySelector(".hover-preview-title");
+ const metaEl = el.querySelector(".hover-preview-meta");
+ const propsEl = el.querySelector(".hover-preview-props");
+
+ if (card) {
+ card.style.minWidth = "420px";
+ card.style.maxWidth = "640px";
+ card.style.minHeight = "220px";
+ card.style.padding = "10px 12px";
+ card.style.overflow = "hidden";
+ }
+
+ if (grid) {
+ grid.style.display = "grid";
+ grid.style.gridTemplateColumns = "220px minmax(260px, 1fr)";
+ grid.style.gap = "12px";
+ grid.style.alignItems = "center";
+ }
+
+ if (leftCol) {
+ leftCol.style.display = "flex";
+ leftCol.style.flexDirection = "column";
+ leftCol.style.justifyContent = "center";
+ leftCol.style.minWidth = "0";
+ }
+
+ if (rightCol) {
+ rightCol.style.display = "flex";
+ rightCol.style.flexDirection = "column";
+ rightCol.style.justifyContent = "center";
+ rightCol.style.minWidth = "0";
+ rightCol.style.overflow = "hidden";
+ }
+
+ if (thumb) {
+ thumb.style.display = "flex";
+ thumb.style.alignItems = "center";
+ thumb.style.justifyContent = "center";
+ thumb.style.minHeight = "140px";
+ thumb.style.marginBottom = "6px";
+ }
+
+ if (snippet) {
+ snippet.style.marginTop = "4px";
+ snippet.style.maxHeight = "140px";
+ snippet.style.overflow = "auto";
+ snippet.style.fontSize = "0.78rem";
+ snippet.style.whiteSpace = "pre-wrap";
+ snippet.style.padding = "6px 8px";
+ snippet.style.borderRadius = "6px";
+ // Dark-mode friendly styling that still looks OK in light mode
+ //snippet.style.backgroundColor = "rgba(39, 39, 39, 0.92)";
+ snippet.style.color = "#e5e7eb";
+ }
+
+ if (titleEl) {
+ titleEl.style.fontWeight = "600";
+ titleEl.style.fontSize = "0.95rem";
+ titleEl.style.marginBottom = "2px";
+ titleEl.style.whiteSpace = "nowrap";
+ titleEl.style.overflow = "hidden";
+ titleEl.style.textOverflow = "ellipsis";
+ titleEl.style.maxWidth = "100%";
+ }
+
+ if (metaEl) {
+ metaEl.style.fontSize = "0.8rem";
+ metaEl.style.opacity = "0.8";
+ metaEl.style.marginBottom = "6px";
+ metaEl.style.whiteSpace = "nowrap";
+ metaEl.style.overflow = "hidden";
+ metaEl.style.textOverflow = "ellipsis";
+ metaEl.style.maxWidth = "100%";
+ }
+
+ if (propsEl) {
+ propsEl.style.fontSize = "0.78rem";
+ propsEl.style.lineHeight = "1.3";
+ propsEl.style.maxHeight = "160px";
+ propsEl.style.overflow = "auto";
+ propsEl.style.paddingRight = "4px";
+ propsEl.style.wordBreak = "break-word";
+ }
+
+ // Allow the user to move onto the card without it vanishing
+ el.addEventListener("mouseenter", () => {
+ hoverPreviewHoveringCard = true;
+ });
+
+ el.addEventListener("mouseleave", () => {
+ hoverPreviewHoveringCard = false;
+ // If we've left both the row and the card, hide after a tiny delay
+ setTimeout(() => {
+ if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
+ hideHoverPreview();
+ }
+ }, 120);
+ });
+
+ // Click anywhere on the card = open preview/editor/folder
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (!hoverPreviewContext) return;
+
+ const ctx = hoverPreviewContext;
+
+ // Hide the hover card immediately so it doesn't hang around
+ hideHoverPreview();
+
+ if (ctx.type === "file") {
+ openDefaultFileFromHover(ctx.file);
+ } else if (ctx.type === "folder") {
+ const dest = ctx.folder;
+ if (dest) {
+ window.currentFolder = dest;
+ try { localStorage.setItem("lastOpenedFolder", dest); } catch {}
+ updateBreadcrumbTitle(dest);
+ loadFileList(dest);
+ }
+ }
+ });
+
+ return el;
+}
+
+function hideHoverPreview() {
+ cancelHoverPreview();
+}
function applyFolderStripLayout(strip) {
if (!strip) return;
@@ -316,6 +618,105 @@ function fetchFolderStats(folder) {
return p;
}
+// --- Folder "peek" cache (first few child folders/files) ---
+const FOLDER_PEEK_MAX_ITEMS = 6;
+const _folderPeekCache = new Map();
+
+/**
+ * Best-effort peek: first few direct child folders + files for a folder.
+ * Uses existing getFolderList.php + getFileList.php.
+ *
+ * Returns: { items: Array<{type,name}>, truncated: boolean }
+ */
+async function fetchFolderPeek(folder) {
+ if (!folder) return null;
+
+ if (_folderPeekCache.has(folder)) {
+ return _folderPeekCache.get(folder);
+ }
+
+ const p = (async () => {
+ try {
+ // 1) Files in this folder
+ let files = [];
+ try {
+ const res = await fetch(
+ `/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=0&t=${Date.now()}`,
+ { credentials: "include" }
+ );
+ const raw = await safeJson(res);
+ if (Array.isArray(raw.files)) {
+ files = raw.files;
+ } else if (raw.files && typeof raw.files === "object") {
+ files = Object.entries(raw.files).map(([name, meta]) => ({
+ ...(meta || {}),
+ name
+ }));
+ }
+ } catch {
+ // ignore file errors; we can still show folders
+ }
+
+ // 2) Direct subfolders
+ let subfolderNames = [];
+ try {
+ const res2 = await fetch(
+ `/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
+ { credentials: "include" }
+ );
+ const raw2 = await safeJson(res2);
+
+ if (Array.isArray(raw2)) {
+ const allPaths = raw2.map(item => item.folder ?? item);
+ const depth = folder === "root" ? 1 : folder.split("/").length + 1;
+
+ subfolderNames = allPaths
+ .filter(p => {
+ if (folder === "root") return p.indexOf("/") === -1;
+ if (!p.startsWith(folder + "/")) return false;
+ return p.split("/").length === depth;
+ })
+ .map(p => p.split("/").pop() || p);
+ }
+ } catch {
+ // ignore folder errors
+ }
+
+ const items = [];
+
+ // Folders first
+ for (const name of subfolderNames) {
+ if (!name) continue;
+ items.push({ type: "folder", name });
+ if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
+ }
+
+ // Then a few files
+ if (items.length < FOLDER_PEEK_MAX_ITEMS && Array.isArray(files)) {
+ for (const f of files) {
+ if (!f || !f.name) continue;
+ items.push({ type: "file", name: f.name });
+ if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
+ }
+ }
+
+ // Were there more candidates than we showed?
+ const totalCandidates =
+ (Array.isArray(subfolderNames) ? subfolderNames.length : 0) +
+ (Array.isArray(files) ? files.length : 0);
+
+ const truncated = totalCandidates > items.length;
+
+ return { items, truncated };
+ } catch {
+ return null;
+ }
+ })();
+
+ _folderPeekCache.set(folder, p);
+ return p;
+}
+
/* ===========================================================
SECURITY: build file URLs only via the API (no /uploads)
=========================================================== */
@@ -383,6 +784,258 @@ function wireSelectAll(fileListContent) {
syncHeader();
}
+function fillHoverPreviewForRow(row) {
+ if (isHoverPreviewDisabled()) {
+ hideHoverPreview();
+ return;
+ }
+
+ const el = ensureHoverPreviewEl();
+ const titleEl = el.querySelector(".hover-preview-title");
+ const metaEl = el.querySelector(".hover-preview-meta");
+ const thumbEl = el.querySelector(".hover-preview-thumb");
+ const propsEl = el.querySelector(".hover-preview-props");
+ const snippetEl = el.querySelector(".hover-preview-snippet");
+
+ if (!titleEl || !metaEl || !thumbEl || !propsEl || !snippetEl) return;
+
+ // Reset content
+ thumbEl.innerHTML = "";
+ propsEl.innerHTML = "";
+ snippetEl.textContent = "";
+ snippetEl.style.display = "none";
+ metaEl.textContent = "";
+ titleEl.textContent = "";
+
+ // Reset per-row sizing (we only make this tall for images)
+ thumbEl.style.minHeight = "0";
+
+ const isFolder = row.classList.contains("folder-row");
+
+ if (isFolder) {
+ // =========================
+ // FOLDER HOVER PREVIEW
+ // =========================
+ const folderPath = row.dataset.folder || "";
+ const folderName = folderPath.split("/").pop() || folderPath || "(root)";
+
+ titleEl.textContent = folderName;
+
+ hoverPreviewContext = {
+ type: "folder",
+ folder: folderPath
+ };
+
+ // Right column: icon + path
+ const iconHtml = `
+
+ folder
+ ${t("folder") || "Folder"}
+
+ `;
+
+ let propsHtml = iconHtml;
+ propsHtml += `
+
+ ${t("path") || "Path"}: ${escapeHTML(folderPath || "root")}
+
+ `;
+ propsEl.innerHTML = propsHtml;
+
+ // Meta: counts + size
+ fetchFolderStats(folderPath).then(stats => {
+ if (!stats || !document.body.contains(el)) return;
+ if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
+
+ const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
+ const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
+
+ let bytes = null;
+ const sizeCandidates = [stats.bytes, stats.sizeBytes, stats.size, stats.totalBytes];
+ for (const v of sizeCandidates) {
+ const n = Number(v);
+ if (Number.isFinite(n) && n >= 0) {
+ bytes = n;
+ break;
+ }
+ }
+
+ const pieces = [];
+ if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
+ if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
+ if (!pieces.length) pieces.push("0 items");
+
+ const sizeLabel = bytes != null && bytes >= 0 ? formatSize(bytes) : "";
+ metaEl.textContent = sizeLabel
+ ? `${pieces.join(", ")} • ${sizeLabel}`
+ : pieces.join(", ");
+ }).catch(() => {});
+
+ // Left side: peek inside folder (first few children)
+ // Left side: peek inside folder (first few children)
+fetchFolderPeek(folderPath).then(result => {
+ if (!document.body.contains(el)) return;
+ if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
+
+ if (!result) {
+ snippetEl.style.display = "none";
+ return;
+ }
+
+ const { items, truncated } = result;
+
+ // If nothing inside, show a friendly message like files do
+ if (!items || !items.length) {
+ const msg =
+ t("no_files_or_folders") ||
+ t("no_files_found") ||
+ "No files or folders";
+
+ snippetEl.textContent = msg;
+ snippetEl.style.display = "block";
+ return;
+ }
+
+ const lines = items.map(it => {
+ const prefix = it.type === "folder" ? "📁 " : "📄 ";
+ return prefix + it.name;
+ });
+
+ // If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, turn the LAST line into "…"
+ if (truncated && lines.length) {
+ lines[lines.length - 1] = "…";
+ }
+
+ snippetEl.textContent = lines.join("\n");
+ snippetEl.style.display = "block";
+}).catch(() => {});
+
+ } else {
+ // ======================
+ // FILE HOVER PREVIEW
+ // ======================
+ const name = row.getAttribute("data-file-name") || "";
+ const file = fileData.find(f => f.name === name) || null;
+
+ hoverPreviewContext = {
+ type: "file",
+ file
+ };
+
+ if (!file) {
+ titleEl.textContent = name || "(unknown)";
+ metaEl.textContent = "";
+ return;
+ }
+
+ titleEl.textContent = file.name;
+
+ // IMPORTANT: no duplicate "size • modified • owner" under the title
+ metaEl.textContent = "";
+
+ const ext = getFileExt(file.name);
+ const lower = file.name.toLowerCase();
+ const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|heic)$/i.test(lower);
+ const isVideo = /\.(mp4|mkv|webm|mov|ogv)$/i.test(lower);
+ const isAudio = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(lower);
+ const isPdf = /\.pdf$/i.test(lower);
+
+ const folder = file.folder || window.currentFolder || "root";
+ const url = apiFileUrl(folder, file.name, true);
+ const canTextPreview = canEditFile(file.name);
+
+ // Left: image preview OR text snippet OR "No preview"
+ if (isImage) {
+ thumbEl.style.minHeight = "140px";
+ const img = document.createElement("img");
+ img.src = url;
+ img.alt = file.name;
+ img.style.maxWidth = "180px";
+ img.style.maxHeight = "120px";
+ img.style.display = "block";
+ thumbEl.appendChild(img);
+ }
+
+ // Icon type for right column
+ let iconName = "insert_drive_file";
+ if (isImage) iconName = "image";
+ else if (isVideo) iconName = "movie";
+ else if (isAudio) iconName = "audiotrack";
+ else if (isPdf) iconName = "picture_as_pdf";
+
+ const props = [];
+
+ // Icon row at the top of the right column
+ props.push(`
+
+ ${iconName}
+ ${escapeHTML(ext || "").toUpperCase() || t("file") || "File"}
+
+ `);
+
+ if (ext) {
+ props.push(`${t("extension") || "Ext"}: .${escapeHTML(ext)}
`);
+ }
+ if (file.size) {
+ props.push(`${t("size") || "Size"}: ${escapeHTML(file.size)}
`);
+ }
+ if (file.modified) {
+ props.push(`${t("modified") || "Modified"}: ${escapeHTML(file.modified)}
`);
+ }
+ if (file.uploaded) {
+ props.push(`${t("created") || "Created"}: ${escapeHTML(file.uploaded)}
`);
+ }
+ if (file.uploader) {
+ props.push(`${t("owner") || "Owner"}: ${escapeHTML(file.uploader)}
`);
+ }
+
+ propsEl.innerHTML = props.join("");
+
+ // Text snippet (left) for smaller text/code files
+ if (canTextPreview) {
+ fillFileSnippet(file, snippetEl);
+ } else if (!isImage) {
+ // Non-image, non-text → explicit "No preview"
+ const msg = t("no_preview_available") || "No preview available";
+ thumbEl.innerHTML = `
+
+ ${escapeHTML(msg)}
+
+ `;
+ }
+ }
+}
+
+function positionHoverPreview(x, y) {
+ const el = ensureHoverPreviewEl();
+ const CARD_OFFSET_X = 16;
+ const CARD_OFFSET_Y = 12;
+
+ let left = x + CARD_OFFSET_X;
+ let top = y + CARD_OFFSET_Y;
+
+ const rect = el.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ if (left + rect.width > vw - 10) {
+ left = x - rect.width - CARD_OFFSET_X;
+ }
+ if (top + rect.height > vh - 10) {
+ top = y - rect.height - CARD_OFFSET_Y;
+ }
+
+ el.style.left = `${Math.max(4, left)}px`;
+ el.style.top = `${Math.max(4, top)}px`;
+}
// ---- Folder-strip icon helpers (same geometry as tree, but colored inline) ----
function _hexToHsl(hex) {
hex = String(hex || '').replace('#', '');
@@ -932,15 +1585,22 @@ export async function loadFileList(folderParam) {
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
-
- // Prefer numeric size if API provides it; otherwise parse the "1.2 MB" string
+
let bytes = Number.isFinite(f.sizeBytes)
? f.sizeBytes
: parseSizeToBytes(String(f.size || ""));
-
- if (!Number.isFinite(bytes)) bytes = Infinity;
-
- f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
+
+ // If we can't parse a sane size, treat as "unknown" instead of Infinity
+ if (!Number.isFinite(bytes) || bytes < 0) {
+ bytes = null;
+ }
+
+ f.sizeBytes = bytes;
+
+ // For editing: if size is unknown, assume it's OK and let the editor enforce limits.
+ const safeForEdit = (bytes == null) || (bytes <= MAX_EDIT_BYTES);
+ f.editable = canEditFile(f.name) && safeForEdit;
+
f.folder = folder;
return f;
});
@@ -1256,51 +1916,17 @@ if (headerClass) {
} else if (i === actionsIdx) {
td.classList.add("folder-actions-cell");
- const group = document.createElement("div");
- group.className = "btn-group btn-group-sm folder-actions-group";
- group.setAttribute("role", "group");
-group.setAttribute("aria-label", "File actions");
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "btn btn-link btn-actions-ellipsis";
+ btn.title = t("more_actions");
-const makeActionBtn = (iconName, titleKey, btnClass, actionKey, handler) => {
- const btn = document.createElement("button");
- btn.type = "button";
-
- // base classes – same size as file actions
- btn.className = `btn ${btnClass} py-1`;
-
- // kill any Bootstrap margin helpers that got passed in
- btn.classList.remove("ml-2", "mx-2");
-
- btn.setAttribute("data-folder-action", actionKey);
- btn.setAttribute("data-i18n-title", titleKey);
- btn.title = t(titleKey);
-
- const icon = document.createElement("i");
- icon.className = "material-icons";
- icon.textContent = iconName;
- btn.appendChild(icon);
-
- btn.addEventListener("click", e => {
- e.stopPropagation();
- window.currentFolder = sf.full;
- try { localStorage.setItem("lastOpenedFolder", sf.full); } catch {}
- handler();
- });
-
- // start disabled; caps logic will enable
- btn.disabled = true;
- btn.style.pointerEvents = "none";
- btn.style.opacity = "0.5";
-
- group.appendChild(btn);
-};
+ const icon = document.createElement("span");
+ icon.className = "material-icons";
+ icon.textContent = "more_vert";
-makeActionBtn("drive_file_move", "move_folder", "btn-warning folder-move-btn", "move", () => openMoveFolderUI());
-makeActionBtn("palette", "color_folder", "btn-color-folder","color", () => openColorFolderModal(sf.full));
-makeActionBtn("drive_file_rename_outline", "rename_folder", "btn-warning folder-rename-btn", "rename", () => openRenameFolderModal());
-makeActionBtn("share", "share_folder", "btn-secondary", "share", () => openFolderShareModal(sf.full));
-
- td.appendChild(group);
+ btn.appendChild(icon);
+ td.appendChild(btn);
}
// IMPORTANT: always append the cell, no matter which column we're in
@@ -1309,22 +1935,27 @@ makeActionBtn("share", "share_folder", "btn-secondary",
// click → navigate, same as before
tr.addEventListener("click", e => {
+ // If the click came from the 3-dot button, let the context menu logic handle it
+ if (e.target.closest(".btn-actions-ellipsis")) {
+ return;
+ }
+
if (e.button !== 0) return;
const dest = sf.full;
if (!dest) return;
-
+
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
-
+
updateBreadcrumbTitle(dest);
-
+
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
const treeNode = document.querySelector(
`.folder-option[data-folder="${CSS.escape(dest)}"]`
);
if (treeNode) treeNode.classList.add("selected");
-
+
const strip = document.getElementById("folderStripContainer");
if (strip) {
strip.querySelectorAll(".folder-item.selected")
@@ -1334,7 +1965,7 @@ makeActionBtn("share", "share_folder", "btn-secondary",
);
if (stripItem) stripItem.classList.add("selected");
}
-
+
loadFileList(dest);
});
@@ -1563,9 +2194,30 @@ function syncFolderIconSizeToRowHeight() {
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
});
}
+
+async function openDefaultFileFromHover(file) {
+ if (!file) return;
+ const folder = file.folder || window.currentFolder || "root";
+
+ try {
+ if (canEditFile(file.name) && file.editable) {
+ const m = await import('./fileEditor.js?v={{APP_QVER}}');
+ m.editFile(file.name, folder);
+ } else {
+ const url = apiFileUrl(folder, file.name, true);
+ const m = await import('./filePreview.js?v={{APP_QVER}}');
+ m.previewFile(url, file.name);
+ }
+ } catch (e) {
+ console.error("Failed to open hover preview action", e);
+ }
+}
+
/**
* Render table view
*/
+
+
export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
@@ -1680,11 +2332,100 @@ export function renderFileTable(folder, container, subfolders) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
- // Inject inline folder rows for THIS page (Explorer-style)
- if (window.showInlineFolders !== false && pageFolders.length) {
- injectInlineFolderRows(fileListContent, folder, pageFolders);
- }
- wireSelectAll(fileListContent);
+ // ---- MOBILE FIX: show "Size" column for files (Name | Size | Actions) ----
+ (function fixMobileFileSizeColumn() {
+ const isMobile = window.innerWidth <= 640;
+ if (!isMobile) return;
+
+ const table = fileListContent.querySelector("table.filr-table");
+ if (!table || !table.tHead || !table.tBodies.length) return;
+
+ const thead = table.tHead;
+ const tbody = table.tBodies[0];
+
+ const headerCells = Array.from(thead.querySelectorAll("th"));
+ // Find the Size column index by label or data-column
+ const sizeIdx = headerCells.findIndex(th =>
+ (th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
+ /\bsize\b/i.test((th.textContent || "").trim())
+ );
+ if (sizeIdx < 0) return;
+
+ // Unhide Size header on mobile
+ const sizeTh = headerCells[sizeIdx];
+ sizeTh.classList.remove(
+ "hide-small",
+ "hide-medium",
+ "d-none",
+ "d-sm-table-cell",
+ "d-md-table-cell",
+ "d-lg-table-cell",
+ "d-xl-table-cell"
+ );
+
+ // Unhide the Size cell in every body row (files + folders)
+ Array.from(tbody.rows).forEach(row => {
+ if (sizeIdx >= row.cells.length) return;
+ const td = row.cells[sizeIdx];
+ if (!td) return;
+
+ td.classList.remove(
+ "hide-small",
+ "hide-medium",
+ "d-none",
+ "d-sm-table-cell",
+ "d-md-table-cell",
+ "d-lg-table-cell",
+ "d-xl-table-cell"
+ );
+ });
+ })();
+
+// Inject inline folder rows for THIS page (Explorer-style) first
+if (window.showInlineFolders !== false && pageFolders.length) {
+ injectInlineFolderRows(fileListContent, folder, pageFolders);
+}
+
+// Now wire 3-dot ellipsis so it also picks up folder rows
+wireEllipsisContextMenu(fileListContent);
+
+// Hover preview (desktop only, and only if user didn’t disable it)
+if (window.innerWidth >= 768 && !isHoverPreviewDisabled()) {
+ fileListContent.querySelectorAll("tbody tr").forEach(row => {
+ if (row.classList.contains("folder-strip-row")) return;
+
+ row.addEventListener("mouseenter", (e) => {
+ hoverPreviewActiveRow = row;
+ clearTimeout(hoverPreviewTimer);
+ hoverPreviewTimer = setTimeout(() => {
+ if (hoverPreviewActiveRow === row && !isHoverPreviewDisabled()) {
+ fillHoverPreviewForRow(row);
+ const el = ensureHoverPreviewEl();
+ el.style.display = "block";
+ positionHoverPreview(e.clientX, e.clientY);
+ }
+ }, 180);
+ });
+
+ row.addEventListener("mouseleave", () => {
+ hoverPreviewActiveRow = null;
+ clearTimeout(hoverPreviewTimer);
+ setTimeout(() => {
+ if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
+ hideHoverPreview();
+ }
+ }, 120);
+ });
+
+ row.addEventListener("contextmenu", () => {
+ hoverPreviewActiveRow = null;
+ clearTimeout(hoverPreviewTimer);
+ hideHoverPreview();
+ });
+ });
+}
+
+wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs
// PATCH each row's preview/thumb to use the secure API URLs
@@ -1869,7 +2610,10 @@ export function renderFileTable(folder, container, subfolders) {
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});
+
+ // Right-click context menu stays for power users
bindFileListContextMenu();
+
refreshViewedBadges(folder).catch(() => { });
}
diff --git a/public/js/filePreview.js b/public/js/filePreview.js
index fe90862..13c3c3d 100644
--- a/public/js/filePreview.js
+++ b/public/js/filePreview.js
@@ -9,6 +9,18 @@ export function buildPreviewUrl(folder, name) {
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
+// New: build a download URL (attachment)
+export function buildDownloadUrl(folder, name) {
+ const f = (!folder || folder === '') ? 'root' : String(folder);
+ const params = new URLSearchParams({
+ folder: f,
+ file: name,
+ inline: '0',
+ t: String(Date.now())
+ });
+ return `/api/file/download.php?${params.toString()}`;
+}
+
const MEDIA_VOLUME_KEY = 'frMediaVolume';
const MEDIA_MUTED_KEY = 'frMediaMuted';
@@ -376,6 +388,27 @@ function setTitle(overlay, name) {
}
}
+// New: Download icon that uses current file name
+function makeDownloadButton(folder, getName) {
+ const btn = makeTopIcon('download', t('download') || 'Download');
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const nm = getName && getName();
+ if (!nm) return;
+
+ const url = buildDownloadUrl(folder, nm);
+
+ // Use a temporary with download attribute for nicer behavior
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = nm;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ });
+ return btn;
+}
+
// Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button');
@@ -472,6 +505,9 @@ export function previewFile(fileUrl, fileName) {
setTitle(overlay, name);
if (isSvg) {
+ const downloadBtn = makeDownloadButton(folder, () => name);
+ actionWrap.appendChild(downloadBtn);
+
container.textContent =
t("svg_preview_disabled") ||
"SVG preview is disabled for security. Use Download to view this file.";
@@ -490,12 +526,17 @@ export function previewFile(fileUrl, fileName) {
img.dataset.scale = 1;
img.dataset.rotate = 0;
container.appendChild(img);
-
+
+ let currentName = name;
+
// topbar-aligned, theme-aware icons
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
+ const downloadBtn = makeDownloadButton(folder, () => currentName);
+
+ actionWrap.appendChild(downloadBtn);
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
@@ -527,21 +568,22 @@ export function previewFile(fileUrl, fileName) {
});
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
- overlay.mediaType = 'image';
- overlay.mediaList = images;
- overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
- setNavVisibility(overlay, images.length > 1, images.length > 1);
+overlay.mediaType = 'image';
+overlay.mediaList = images;
+overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
+setNavVisibility(overlay, images.length > 1, images.length > 1);
- const navigate = (dir) => {
- if (!overlay.mediaList || overlay.mediaList.length < 2) return;
- overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
- const newFile = overlay.mediaList[overlay.mediaIndex].name;
- setTitle(overlay, newFile);
- img.dataset.scale = 1;
- img.dataset.rotate = 0;
- img.style.transform = 'scale(1) rotate(0deg)';
- img.src = buildPreviewUrl(folder, newFile);
- };
+const navigate = (dir) => {
+ if (!overlay.mediaList || overlay.mediaList.length < 2) return;
+ overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
+ const newFile = overlay.mediaList[overlay.mediaIndex].name;
+ currentName = newFile; // keep download button pointing to the right file
+ setTitle(overlay, newFile);
+ img.dataset.scale = 1;
+ img.dataset.rotate = 0;
+ img.style.transform = 'scale(1) rotate(0deg)';
+ img.src = buildPreviewUrl(folder, newFile);
+};
if (images.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
@@ -582,20 +624,24 @@ export function previewFile(fileUrl, fileName) {
loadSavedMediaVolume(video);
attachVolumePersistence(video);
- // Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
- const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
- actionWrap.appendChild(markBtnIcon);
- actionWrap.appendChild(clearBtnIcon);
-
- const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
- overlay.mediaType = 'video';
- overlay.mediaList = videos;
- overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
- setNavVisibility(overlay, videos.length > 1, videos.length > 1);
-
- // Track which file is currently active
- let currentName = name;
+const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
+
+// Track which file is currently active
+let currentName = name;
+
+const downloadBtn = makeDownloadButton(folder, () => currentName);
+
+// Order: Download | Mark | Reset
+actionWrap.appendChild(downloadBtn);
+actionWrap.appendChild(markBtnIcon);
+actionWrap.appendChild(clearBtnIcon);
+
+const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
+overlay.mediaType = 'video';
+overlay.mediaList = videos;
+overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
+setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => {
currentName = nm;
@@ -744,6 +790,7 @@ export function previewFile(fileUrl, fileName) {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
+ currentName = nm; // keep download button in sync
setVideoSrc(nm);
renderStatus(null);
};
@@ -782,8 +829,14 @@ export function previewFile(fileUrl, fileName) {
loadSavedMediaVolume(audio);
attachVolumePersistence(audio);
+ const downloadBtn = makeDownloadButton(folder, () => name);
+ actionWrap.appendChild(downloadBtn);
+
overlay.style.display = "flex";
} else {
+ const downloadBtn = makeDownloadButton(folder, () => name);
+ actionWrap.appendChild(downloadBtn);
+
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
overlay.style.display = "flex";
}
diff --git a/public/js/folderManager.js b/public/js/folderManager.js
index 3c9b55f..32762ac 100644
--- a/public/js/folderManager.js
+++ b/public/js/folderManager.js
@@ -1066,6 +1066,41 @@ export function openColorFolderModal(folder) {
}
});
}
+
+function addFolderActionButton(rowEl, folderPath) {
+ if (!rowEl || !folderPath) return;
+ if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ // share styling with file list kebab
+ btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
+ btn.textContent = 'more_vert';
+
+ const label = t('folder_actions') || 'Folder actions';
+ btn.title = label;
+ btn.setAttribute('aria-label', label);
+
+ // only control visibility/layout here; let CSS handle colors/hover
+ Object.assign(btn.style, {
+ display: 'none',
+ marginLeft: '4px',
+ flexShrink: '0'
+ });
+
+ btn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = btn.getBoundingClientRect();
+ const x = rect.right;
+ const y = rect.bottom;
+ const opt = rowEl.querySelector('.folder-option');
+ await openFolderActionsMenu(folderPath, opt, x, y);
+ });
+
+ rowEl.appendChild(btn);
+}
+
/* ----------------------
DOM builders & DnD
----------------------*/
@@ -1125,6 +1160,10 @@ function makeChildLi(parentPath, item) {
opt.append(icon, label);
row.append(spacer, opt);
+
+ // Add 3-dot actions button for unlocked folders
+ if (!locked) addFolderActionButton(row, fullPath);
+
li.append(row);
//
@@ -1300,6 +1339,28 @@ function getULForFolder(folder) {
const li = opt ? opt.closest('li[role="treeitem"]') : null;
return li ? li.querySelector(':scope > ul.folder-tree') : null;
}
+
+function updateFolderActionButtons() {
+ const container = document.getElementById('folderTreeContainer');
+ if (!container) return;
+
+ // Hide all kebabs by default
+ container.querySelectorAll('.folder-kebab').forEach(btn => {
+ btn.style.display = 'none';
+ });
+
+ // Show only for the currently selected, unlocked folder
+ const selectedOpt = container.querySelector('.folder-option.selected');
+ if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
+
+ const row = selectedOpt.closest('.folder-row');
+ if (!row) return;
+ const kebab = row.querySelector('.folder-kebab');
+ if (kebab) {
+ kebab.style.display = 'inline-flex';
+ }
+}
+
async function selectFolder(selected) {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
@@ -1368,6 +1429,9 @@ async function selectFolder(selected) {
saveFolderTreeState(st);
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
}
+
+ // Keep the 3-dot action aligned to the active folder
+ updateFolderActionButtons();
}
/* ----------------------
@@ -1432,6 +1496,12 @@ export async function loadFolderTree(selectedFolder) {
`;
container.innerHTML = html;
+ // Add 3-dot actions button for root
+ const rootRow = document.getElementById('rootRow');
+ if (rootRow) {
+ addFolderActionButton(rootRow, effectiveRoot);
+ }
+
// Determine root's lock state
const rootOpt = container.querySelector('.root-folder-option');
let rootLocked = false;
@@ -1654,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
if (menu) menu.hidden = true;
}
+async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
+ if (!folder) return;
+
+ window.currentFolder = folder;
+ await applyFolderCapabilities(folder);
+
+ // Clear previous selection in tree + breadcrumb
+ document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
+
+ // Mark the clicked thing selected (folder-option or breadcrumb)
+ if (targetEl) targetEl.classList.add('selected');
+
+ // Also sync selection in the tree if we invoked from a breadcrumb or kebab
+ const tree = document.getElementById('folderTreeContainer');
+ if (tree) {
+ const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
+ if (inTree) inTree.classList.add('selected');
+ }
+
+ // Show the kebab only for this selected folder
+ updateFolderActionButtons();
+
+ const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
+
+ const menuItems = [
+ {
+ label: t('create_folder'),
+ action: () => {
+ const modal = document.getElementById('createFolderModal');
+ const input = document.getElementById('newFolderName');
+ if (modal) modal.style.display = 'block';
+ if (input) input.focus();
+ }
+ },
+ { label: t('move_folder'), action: () => openMoveFolderUI(folder) },
+ { label: t('rename_folder'), action: () => openRenameFolderModal() },
+ ...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
+ { label: t('folder_share'), action: () => openFolderShareModal(folder) },
+ { label: t('delete_folder'), action: () => openDeleteFolderModal() },
+ ];
+
+ showFolderManagerContextMenu(clientX, clientY, menuItems);
+}
+
async function folderManagerContextMenuHandler(e) {
const target = e.target.closest('.folder-option, .breadcrumb-link');
if (!target) return;
e.preventDefault();
e.stopPropagation();
- // Toggle-only for locked nodes
+ // Toggle-only for locked nodes (no menu)
if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder);
@@ -1679,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
const folder = target.getAttribute('data-folder');
if (!folder) return;
- window.currentFolder = folder;
- await applyFolderCapabilities(folder);
-
- document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
- target.classList.add('selected');
-
- const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
-
- const menuItems = [
- { label: t('create_folder'), action: () => {
- const modal = document.getElementById('createFolderModal');
- const input = document.getElementById('newFolderName');
- if (modal) modal.style.display = 'block';
- if (input) input.focus();
- }},
- { label: t('move_folder'), action: () => openMoveFolderUI(folder) },
- { label: t('rename_folder'), action: () => openRenameFolderModal() },
- ...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
- { label: t('folder_share'), action: () => openFolderShareModal(folder) },
- { label: t('delete_folder'), action: () => openDeleteFolderModal() },
- ];
-
- showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
+ const x = e.clientX;
+ const y = e.clientY;
+ await openFolderActionsMenu(folder, target, x, y);
}
function bindFolderManagerContextMenu() {
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 316716d..ecf49ab 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -343,7 +343,16 @@ const translations = {
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
- "svg_preview_disabled": "SVG preview is disabled for now for security reasons."
+ "svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
+ "no_files_or_folders": "No files or folders to display.",
+ "no_preview_available": "No preview available.",
+ "more_actions": "More Actions",
+ "folder_actions": "Folder Actions",
+ "disable_hover_preview": "Disable hover preview in file list",
+ "zoom_in": "Zoom In",
+ "zoom_out": "Zoom Out",
+ "rotate_left": "Rotate Left",
+ "rotate_right": "Rotate Right"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",