release(v2.3.1): polish file list actions & hover preview peak
This commit is contained in:
14
CHANGELOG.md
14
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 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(() => { });
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user