release(v2.3.1): polish file list actions & hover preview peak

This commit is contained in:
Ryan
2025-12-03 00:29:08 -05:00
committed by GitHub
parent e2d1b705bd
commit b417217552
9 changed files with 1469 additions and 204 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -163,9 +163,9 @@ export function buildFileTableHeader(sortOrder) {
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="sortable-col"> ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} </th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th>
<th data-column="actions" class="actions-col">${t("actions")}</th>
</tr>
</thead>
`;
@@ -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 = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`;
} else if (/\.pdf$/i.test(file.name)) {
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`;
}
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
}
return `
<tr class="clickable-row">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
<button
type="button"
class="btn btn-sm btn-success download-btn"
data-download-name="${file.name}"
data-download-folder="${file.folder || 'root'}"
title="${t('download')}">
<i class="material-icons">file_download</i>
</button>
${file.editable ? `
<button
type="button"
class="btn btn-sm btn-secondary edit-btn"
data-edit-name="${file.name}"
data-edit-folder="${file.folder || 'root'}"
title="${t('edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
${previewButton}
<button
type="button"
class="btn btn-sm btn-warning rename-btn"
data-rename-name="${file.name}"
data-rename-folder="${file.folder || 'root'}"
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<!-- share -->
<button
<tr class="clickable-row" data-file-name="${safeFileName}">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell name-cell">
${safeFileName}
</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap size-cell">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td class="actions-cell">
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
class="btn btn-link btn-actions-ellipsis"
title="${t("more_actions")}"
>
<span class="material-icons">more_vert</span>
</button>
</div>
</td>
</tr>
`;
</td>
</tr>
`;
}
export function buildBottomControls(itemsPerPageSetting) {

View File

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

View File

@@ -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 = `
<div class="hover-preview-card">
<div class="hover-preview-grid">
<div class="hover-preview-left">
<div class="hover-preview-thumb"></div>
<pre class="hover-preview-snippet"></pre>
</div>
<div class="hover-preview-right">
<div class="hover-preview-title"></div>
<div class="hover-preview-meta"></div>
<div class="hover-preview-props"></div>
</div>
</div>
</div>
`;
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 = `
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
<strong>${t("folder") || "Folder"}</strong>
</div>
`;
let propsHtml = iconHtml;
propsHtml += `
<div class="hover-prop-line">
<strong>${t("path") || "Path"}:</strong> ${escapeHTML(folderPath || "root")}
</div>
`;
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(`
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
<span class="hover-preview-icon material-icons" style="margin-right:6px;">${iconName}</span>
<strong>${escapeHTML(ext || "").toUpperCase() || t("file") || "File"}</strong>
</div>
`);
if (ext) {
props.push(`<div class="hover-prop-line"><strong>${t("extension") || "Ext"}:</strong> .${escapeHTML(ext)}</div>`);
}
if (file.size) {
props.push(`<div class="hover-prop-line"><strong>${t("size") || "Size"}:</strong> ${escapeHTML(file.size)}</div>`);
}
if (file.modified) {
props.push(`<div class="hover-prop-line"><strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(file.modified)}</div>`);
}
if (file.uploaded) {
props.push(`<div class="hover-prop-line"><strong>${t("created") || "Created"}:</strong> ${escapeHTML(file.uploaded)}</div>`);
}
if (file.uploader) {
props.push(`<div class="hover-prop-line"><strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(file.uploader)}</div>`);
}
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 = `
<div style="
padding:6px 8px;
border-radius:6px;
font-size:0.8rem;
text-align:center;
background-color:rgba(15,23,42,0.92);
color:#e5e7eb;
max-width:100%;
">
${escapeHTML(msg)}
</div>
`;
}
}
}
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 didnt 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(() => { });
}

View File

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

View File

@@ -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);
// <ul class="folder-tree collapsed" role="group"></ul>
@@ -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() {

View File

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