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.",