release(v2.13.0): inline rename + video preview limits + folder tree perf (see #79)

This commit is contained in:
Ryan
2025-12-30 02:55:24 -05:00
committed by GitHub
parent d12c3eba3b
commit 5ea34e4a30
17 changed files with 1044 additions and 93 deletions
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## Changes 12/30/2025 (v2.13.0)
`release(v2.13.0): inline rename + video preview limits + folder tree perf (see #79)`
**Added**
- **Inline rename**:
- File list inline rename (table view) + context-menu support
- Folder tree inline rename (tree context menu + Rename button)
- Keyboard shortcuts: **F2 rename**, plus **Ctrl/Cmd+Shift+N** (new folder)
- **Admin Panel setting (Display):** Hover preview max video size (MB)
- Applies to hover previews + Gallery video thumbnails
- Folder APIs:
- `GET /api/folder/getFolderList.php?counts=0` to skip metadata count reads (faster on large trees)
- `GET /api/folder/listChildren.php?probe=0` to skip per-child “has subfolders / non-empty” probing (faster)
**Changed**
- Hover previews & Gallery video thumbs:
- Use the new video size limit setting
- Improved “no preview available” fallback when a frame cant be decoded quickly
- File streaming:
- Improved HTTP Range support (incl. suffix ranges like `bytes=-500`)
- Expanded safe inline rendering to allowlisted video/audio types when requested (`inline=1`)
**Fixed**
- Context menus now position correctly using `clientX/clientY` (more reliable across layouts).
- Blank folder icons are repaired after drag/drop moves and dual-pane refreshes.
- Admin “Folder Access” modal help text is now collapsible (“More/Less”) for readability.
**Performance**
- Reduced IO for large installs by:
- Avoiding folder count reads when not needed (`counts=0`)
- Avoiding directory iterator probes when not needed (`probe=0`)
---
## Changes 12/29/2025 (v2.12.1)
`release(v2.12.1): folder summary depth + video thumbnails + dual-pane toggle fix`
+7 -1
View File
@@ -41,6 +41,12 @@ $folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim(
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
$probeRaw = $_GET['probe'] ?? null;
$probe = true;
if ($probeRaw !== null) {
$pv = strtolower((string)$probeRaw);
if ($pv === '0' || $pv === 'false' || $pv === 'no') $probe = false;
}
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit, $probe);
echo json_encode($res, JSON_UNESCAPED_SLASHES);
+18
View File
@@ -803,6 +803,24 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
:is(#fileList, #fileListSecondary) table.filr-table tbody tr:focus-within > td{outline: none;}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr:focus-within > td:first-child,
:is(#fileList, #fileListSecondary) table.filr-table tbody tr:focus-within > td:last-child{outline: 2px solid #8ab4f8; outline-offset: -2px;}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active:focus-within > td{
border-radius: 0;
}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active:focus-within > td:first-child{
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active:focus-within > td:last-child{
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active:focus-within > td:first-child,
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active:focus-within > td:last-child{
outline: none;
}
:is(#fileList, #fileListSecondary) table.filr-table tbody tr.inline-rename-active > td{
box-shadow: none !important;
}
#fileListTitle,
#fileListTitleSecondary,
.file-list-title{white-space: normal !important;
+21 -2
View File
@@ -117,7 +117,7 @@ let __allFoldersCache = null;
async function getAllFolders(force = false) {
if (!force && __allFoldersCache) return __allFoldersCache.slice();
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
const res = await fetch('/api/folder/getFolderList.php?counts=0&ts=' + Date.now(), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
@@ -940,7 +940,13 @@ export function openUserPermissionsModal(initialUser = null) {
<span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span>
<h3>${tf("folder_access", "Folder Access")}</h3>
<div class="muted" style="margin:-4px 0 10px;">
${tf("grant_folders_help", "Grant per-folder capabilities to each user. View (all) shows all contents; View (own) shows only the user's uploads. Write is file-level ops (upload/edit/rename/copy/delete/extract). Create is file-only; subfolders require Manage/Ownership. Manage/Ownership enables folder actions (create/rename/move/delete, grant access) and implies View (all), Write, and Share. Share File auto-enables View (own); Share Folder requires Manage/Ownership + View (all).")}
<span class="grant-help-short">${tf("grant_folders_help_short", "Per-folder access. Create is file-only; subfolders need Manage/Ownership. Share Folder needs Manage + View (all).")}</span>
<button type="button" class="btn btn-link btn-sm p-0 grant-help-toggle" aria-expanded="false" style="margin-left:6px;">
${tf("help_more", "More")}
</button>
<span class="grant-help-full" style="display:none;">
${tf("grant_folders_help", "Grant per-folder capabilities to each user. View (all) shows all contents; View (own) shows only the user's uploads. Write is file-level ops (upload/edit/rename/copy/delete/extract). Create is file-only; subfolders require Manage/Ownership. Manage/Ownership enables folder actions (create/rename/move/delete, grant access) and implies View (all), Write, and Share. Share File auto-enables View (own); Share Folder requires Manage/Ownership + View (all).")}
</span>
</div>
<div id="userPermissionsList" style="max-height: 82vh; min-height: 420px; overflow-y: auto; margin-bottom: 15px;">
</div>
@@ -982,6 +988,19 @@ export function openUserPermissionsModal(initialUser = null) {
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
}
});
const helpToggle = userPermissionsModal.querySelector('.grant-help-toggle');
if (helpToggle) {
helpToggle.addEventListener('click', () => {
const expanded = helpToggle.getAttribute('aria-expanded') === 'true';
const next = !expanded;
const shortText = userPermissionsModal.querySelector('.grant-help-short');
const fullText = userPermissionsModal.querySelector('.grant-help-full');
if (shortText) shortText.style.display = next ? 'none' : 'inline';
if (fullText) fullText.style.display = next ? 'inline' : 'none';
helpToggle.setAttribute('aria-expanded', next ? 'true' : 'false');
helpToggle.textContent = next ? tf('help_less', 'Less') : tf('help_more', 'More');
});
}
} else {
userPermissionsModal.style.display = "flex";
}
+35 -1
View File
@@ -1104,6 +1104,7 @@ function captureInitialAdminConfig() {
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
hoverPreviewMaxImageMb: (document.getElementById("hoverPreviewMaxImageMb")?.value || "").trim(),
hoverPreviewMaxVideoMb: (document.getElementById("hoverPreviewMaxVideoMb")?.value || "").trim(),
fileListSummaryDepth: (document.getElementById("fileListSummaryDepth")?.value || "").trim(),
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
@@ -1145,6 +1146,7 @@ function hasUnsavedChanges() {
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") ||
getVal("hoverPreviewMaxImageMb") !== (o.hoverPreviewMaxImageMb || "") ||
getVal("hoverPreviewMaxVideoMb") !== (o.hoverPreviewMaxVideoMb || "") ||
getVal("fileListSummaryDepth") !== (o.fileListSummaryDepth || "") ||
getChk("clamavScanUploads") !== o.clamavScanUploads ||
getChk("proSearchEnabled") !== o.proSearchEnabled ||
@@ -2232,6 +2234,10 @@ export function openAdminPanel() {
1,
Math.min(50, parseInt(displayCfg.hoverPreviewMaxImageMb || 8, 10) || 8)
);
const hoverPreviewMaxVideoMb = Math.max(
1,
Math.min(2048, parseInt(displayCfg.hoverPreviewMaxVideoMb || 200, 10) || 200)
);
const rawSummaryDepth = parseInt(displayCfg.fileListSummaryDepth, 10);
const fileListSummaryDepth = Math.max(
0,
@@ -2503,7 +2509,7 @@ export function openAdminPanel() {
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("hover_preview_max_image_help", "Applies to hover previews and gallery thumbnails; larger values can increase bandwidth and memory use.")}
${tf("hover_preview_max_image_help", "Applies to hover previews and gallery thumbnails. Default 8 MB; higher values increase bandwidth and memory use.")}
</small>
<input
type="number"
@@ -2516,6 +2522,27 @@ export function openAdminPanel() {
/>
</div>
<!-- Display: Hover preview max video size -->
<div class="form-group" style="margin-top:16px;">
<label for="hoverPreviewMaxVideoMb">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("hover_preview_max_video_mb", "Hover preview max video size (MB)")}
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("hover_preview_max_video_help", "Applies to hover previews and gallery thumbnails. Default 200 MB; higher values can increase bandwidth on large videos.")}
</small>
<input
type="number"
id="hoverPreviewMaxVideoMb"
class="form-control"
min="1"
max="2048"
step="1"
value="${hoverPreviewMaxVideoMb}"
/>
</div>
<!-- Display: File list summary depth -->
<div class="form-group" style="margin-top:16px;">
<label for="fileListSummaryDepth">
@@ -4102,6 +4129,13 @@ function handleSave() {
parseInt(document.getElementById("hoverPreviewMaxImageMb")?.value || "8", 10) || 8
)
),
hoverPreviewMaxVideoMb: Math.max(
1,
Math.min(
2048,
parseInt(document.getElementById("hoverPreviewMaxVideoMb")?.value || "200", 10) || 200
)
),
fileListSummaryDepth: Math.max(
0,
Math.min(
+1 -1
View File
@@ -227,7 +227,7 @@ const __portalSubmissionsCache = {};
async function loadPortalFolderList() {
if (__portalFolderListLoaded) return __portalFolderOptions;
try {
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
const res = await fetch('/api/folder/getFolderList.php?counts=0', { credentials: 'include' });
const data = await res.json();
let list = data;
+2 -1
View File
@@ -30,7 +30,8 @@ const DEFAULT_SUPPORTERS = [
'Rob Parker',
'Aaron W.',
'C-Fu',
'peterchia'
'peterchia',
'Edisto Pirates of SC'
];
/**
+17 -3
View File
@@ -5,6 +5,7 @@ import {
formatFolderName,
fileData,
downloadSelectedFilesIndividually,
startInlineRenameFromContext,
MAX_NONZIP_MULTI_DOWNLOAD
} from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon, updateRecycleBinState } from './folderManager.js?v={{APP_QVER}}';
@@ -705,7 +706,7 @@ export async function loadCopyMoveFolderListForModal(dropdownId, preferredFolder
if (window.userFolderOnly) {
const username = localStorage.getItem("username") || "root";
try {
const response = await fetch("/api/folder/getFolderList.php?restricted=1");
const response = await fetch("/api/folder/getFolderList.php?restricted=1&counts=0");
let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder);
@@ -736,7 +737,7 @@ export async function loadCopyMoveFolderListForModal(dropdownId, preferredFolder
}
try {
const response = await fetch("/api/folder/getFolderList.php");
const response = await fetch("/api/folder/getFolderList.php?counts=0");
let folders = await response.json();
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder);
@@ -896,7 +897,20 @@ export function handleRenameSelected(e) {
return;
}
const file = files[0];
renameFile(file.name, file.folder || window.currentFolder || "root");
const folder = file.folder || window.currentFolder || "root";
// Prefer inline rename in table view when we can resolve a row.
try {
if (window.viewMode === "table" && typeof startInlineRenameFromContext === "function") {
const checked = getActiveSelectedFileCheckboxes();
const row = checked.length ? checked[0].closest("tr") : null;
if (row && startInlineRenameFromContext(file, row)) {
return;
}
}
} catch (err) { /* ignore */ }
renameFile(file.name, folder);
}
export function handleShareSelected(e) {
+12 -3
View File
@@ -1,6 +1,6 @@
// fileDragDrop.js
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
import { loadFileList, cancelHoverPreview, repairBlankFolderIcons } from './fileListView.js?v={{APP_QVER}}';
import {
getParentFolder,
syncTreeAfterFolderMove,
@@ -31,6 +31,14 @@ function invalidateFolderStats(folders) {
console.warn('folderStatsInvalidated failed', e);
}
}
function scheduleBlankFolderIconRepair() {
try {
const kick = () => { try { repairBlankFolderIcons({ force: true }); } catch (e) {} };
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
setTimeout(kick, 80);
setTimeout(kick, 250);
} catch (e) { /* ignore */ }
}
function getNameFromAny(el) {
const row = getRowEl(el);
if (!row) return null;
@@ -248,6 +256,7 @@ export async function folderDropHandler(event) {
// Let folderManager handle tree refresh + selection + file list reload
await syncTreeAfterFolderMove(source, dstParent);
scheduleBlankFolderIconRepair();
} catch (e) {
console.error('Error moving folder:', e);
@@ -308,7 +317,7 @@ export async function folderDropHandler(event) {
// keep stats fresh for source + dest
invalidateFolderStats([sourceFolder, dropFolder]);
loadFileList(window.currentFolder || sourceFolder);
loadFileList(window.currentFolder || sourceFolder).finally(scheduleBlankFolderIconRepair);
} else {
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
showToast('Error moving file(s): ' + err);
@@ -317,4 +326,4 @@ export async function folderDropHandler(event) {
console.error('Error moving file(s):', e);
showToast('Error moving file(s).');
}
}
}
+511 -31
View File
@@ -19,7 +19,8 @@ import {
openDownloadModal,
handleCopySelected,
handleMoveSelected,
handleDeleteSelected
handleDeleteSelected,
handleRenameSelected
} from './fileActions.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import {
@@ -29,10 +30,12 @@ import {
showFolderManagerContextMenu,
hideFolderManagerContextMenu,
openRenameFolderModal,
renameFolderInline,
openDeleteFolderModal,
refreshFolderIcon,
openColorFolderModal,
openMoveFolderUI,
startInlineRenameInTree,
startFolderCryptoJobFlow,
folderSVG,
expandTreePath,
@@ -343,7 +346,7 @@ function wireFolderStripItems(strip) {
}
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
// Defensive: some browsers/styles occasionally blank the SVG after a contextmenu.
// If it happens, repaint just this icon.
@@ -452,6 +455,9 @@ const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
const DEFAULT_HOVER_PREVIEW_MAX_MB = 8;
const MIN_HOVER_PREVIEW_MAX_MB = 1;
const MAX_HOVER_PREVIEW_MAX_MB = 50;
const DEFAULT_HOVER_PREVIEW_MAX_VIDEO_MB = 200;
const MIN_HOVER_PREVIEW_MAX_VIDEO_MB = 1;
const MAX_HOVER_PREVIEW_MAX_VIDEO_MB = 2048;
const DEFAULT_FILE_LIST_SUMMARY_DEPTH = 2;
const MIN_FILE_LIST_SUMMARY_DEPTH = 0;
const MAX_FILE_LIST_SUMMARY_DEPTH = 10;
@@ -471,6 +477,15 @@ function getMaxImagePreviewBytes() {
return clamped * 1024 * 1024;
}
function getMaxVideoPreviewBytes() {
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
const display = (cfg && typeof cfg.display === 'object') ? cfg.display : {};
const raw = parseInt(display.hoverPreviewMaxVideoMb, 10);
const mb = Number.isFinite(raw) ? raw : DEFAULT_HOVER_PREVIEW_MAX_VIDEO_MB;
const clamped = Math.max(MIN_HOVER_PREVIEW_MAX_VIDEO_MB, Math.min(MAX_HOVER_PREVIEW_MAX_VIDEO_MB, mb));
return clamped * 1024 * 1024;
}
function getFileListSummaryDepth() {
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
const display = (cfg && typeof cfg.display === 'object') ? cfg.display : {};
@@ -673,6 +688,292 @@ function wireEllipsisContextMenu(fileListContent) {
});
}
let inlineRenameState = null;
function clearInlineRenameState({ restore = true } = {}) {
const state = inlineRenameState;
if (!state) return;
inlineRenameState = null;
try {
if (state.input && state.input.parentNode) {
state.input.parentNode.removeChild(state.input);
}
} catch (e) { /* ignore */ }
if (restore) {
if (state.type === 'file' && state.nameCell) {
state.nameCell.innerHTML = state.originalHtml;
} else if (state.type === 'folder' && state.nameSpan) {
state.nameSpan.style.display = '';
state.nameSpan.textContent = state.originalName;
}
}
if (state.row) {
state.row.classList.remove('inline-rename-active');
if (state.prevDraggable == null) {
state.row.removeAttribute('draggable');
} else {
state.row.setAttribute('draggable', state.prevDraggable);
}
}
}
function focusRenameInput(input, name, selectBase) {
try {
input.focus();
if (selectBase) {
const lastDot = String(name || '').lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
} else {
input.select();
}
} catch (e) { /* ignore */ }
}
function startInlineRenameForFileRow(row, file, folder) {
if (!row || !file) return false;
if (window.viewMode !== 'table') return false;
const nameCell = row.querySelector('.file-name-cell');
if (!nameCell) return false;
clearInlineRenameState();
hideHoverPreview();
const oldName = file.name || '';
const originalHtml = nameCell.innerHTML;
nameCell.textContent = '';
const input = document.createElement('input');
input.type = 'text';
input.value = oldName;
input.className = 'inline-rename-input form-control';
input.setAttribute('aria-label', t('rename') || 'Rename');
input.style.width = '100%';
input.style.maxWidth = '100%';
input.style.fontSize = 'inherit';
input.style.padding = '2px 6px';
input.style.height = 'auto';
input.style.boxSizing = 'border-box';
nameCell.appendChild(input);
const state = {
type: 'file',
row,
input,
nameCell,
originalHtml,
oldName,
folder: folder || window.currentFolder || 'root',
submitting: false,
prevDraggable: row.getAttribute('draggable')
};
inlineRenameState = state;
row.setAttribute('draggable', 'false');
row.classList.add('inline-rename-active');
const commit = async () => {
if (!inlineRenameState || inlineRenameState !== state || state.submitting) return;
const newName = String(input.value || '').trim();
if (!newName || newName === oldName) {
clearInlineRenameState();
return;
}
state.submitting = true;
input.disabled = true;
try {
const res = await fetch(withBase('/api/file/renameFile.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({
folder: state.folder,
oldName: oldName,
newName: newName
})
});
const data = await res.json().catch(() => ({}));
if (data && data.success) {
showToast('File renamed successfully!');
clearInlineRenameState({ restore: false });
loadFileList(state.folder);
return;
}
showToast('Error renaming file: ' + (data.error || 'Unknown error'));
} catch (err) {
console.error('Error renaming file:', err);
showToast('Error renaming file');
}
if (inlineRenameState === state) {
state.submitting = false;
input.disabled = false;
focusRenameInput(input, newName, true);
}
};
const cancel = () => {
clearInlineRenameState();
};
const stop = (e) => { e.stopPropagation(); };
input.addEventListener('mousedown', stop);
input.addEventListener('click', stop);
input.addEventListener('dragstart', stop);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commit();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancel();
}
});
input.addEventListener('blur', () => {
if (inlineRenameState === state && !state.submitting) {
commit();
}
});
requestAnimationFrame(() => {
focusRenameInput(input, oldName, true);
});
return true;
}
function startInlineRenameForFolderRow(row, folderPath) {
if (!row || !folderPath) return false;
if (window.viewMode !== 'table') return false;
const wrap = row.querySelector('.folder-row-inner');
const nameSpan = row.querySelector('.folder-row-name');
if (!wrap || !nameSpan) return false;
clearInlineRenameState();
hideHoverPreview();
const oldName = nameSpan.textContent || (String(folderPath).split('/').pop() || '');
const originalName = oldName;
const input = document.createElement('input');
input.type = 'text';
input.value = oldName;
input.className = 'inline-rename-input form-control';
input.setAttribute('aria-label', t('rename_folder') || 'Rename folder');
input.style.width = '100%';
input.style.maxWidth = '100%';
input.style.fontSize = 'inherit';
input.style.padding = '2px 6px';
input.style.height = 'auto';
input.style.boxSizing = 'border-box';
const metaSpan = wrap.querySelector('.folder-row-meta');
nameSpan.style.display = 'none';
if (metaSpan) {
wrap.insertBefore(input, metaSpan);
} else {
wrap.appendChild(input);
}
const state = {
type: 'folder',
row,
input,
nameSpan,
originalName,
folderPath,
submitting: false,
prevDraggable: row.getAttribute('draggable')
};
inlineRenameState = state;
row.setAttribute('draggable', 'false');
row.classList.add('inline-rename-active');
const commit = async () => {
if (!inlineRenameState || inlineRenameState !== state || state.submitting) return;
const newName = String(input.value || '').trim();
if (!newName || newName === oldName) {
clearInlineRenameState();
return;
}
state.submitting = true;
input.disabled = true;
try {
const result = await renameFolderInline(folderPath, newName, { selectAfter: false });
if (result && result.success) {
clearInlineRenameState({ restore: false });
const reloadFolder = window.currentFolder || getParentFolder(folderPath);
loadFileList(reloadFolder);
return;
}
} catch (err) {
console.error('Error renaming folder:', err);
}
if (inlineRenameState === state) {
state.submitting = false;
input.disabled = false;
focusRenameInput(input, newName, false);
}
};
const cancel = () => {
clearInlineRenameState();
};
const stop = (e) => { e.stopPropagation(); };
input.addEventListener('mousedown', stop);
input.addEventListener('click', stop);
input.addEventListener('dragstart', stop);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commit();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancel();
}
});
input.addEventListener('blur', () => {
if (inlineRenameState === state && !state.submitting) {
commit();
}
});
requestAnimationFrame(() => {
focusRenameInput(input, oldName, false);
});
return true;
}
export function startInlineRenameFromContext(file, row) {
if (!file || !row) return false;
if (row.classList.contains('folder-row')) return false;
return startInlineRenameForFileRow(row, file, file.folder || window.currentFolder || 'root');
}
let hoverPreviewEl = null;
let hoverPreviewTimer = null;
let hoverPreviewActiveRow = null;
@@ -936,9 +1237,46 @@ window.addEventListener('resize', () => {
window.addEventListener('keydown', async (e) => {
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
if (isTextEntryTarget(e.target)) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
const key = e.key;
if (key === 'F2' && !(e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
if (inlineRenameState && inlineRenameState.input) {
try {
inlineRenameState.input.focus();
inlineRenameState.input.select();
} catch (e2) { /* ignore */ }
return;
}
const folderCb = document.querySelector('#fileList .folder-checkbox:checked');
if (folderCb) {
const folder = folderCb.dataset.folder || '';
const row = folderCb.closest('tr');
if (folder && row) {
startInlineRenameForFolderRow(row, folder);
return;
}
}
const fileCbs = document.querySelectorAll('#fileList .file-checkbox:checked');
if (fileCbs.length) {
handleRenameSelected(new Event('click'));
return;
}
const treeFolder = getSelectedTreeFolderPath() || window.currentFolder || 'root';
try {
await startInlineRenameInTree(treeFolder);
} catch (e2) { /* ignore */ }
return;
}
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (!['F3', 'F4', 'F5', 'F6', 'F7', 'F8'].includes(key)) return;
e.preventDefault();
e.stopPropagation();
@@ -986,6 +1324,41 @@ window.addEventListener('keydown', async (e) => {
}
});
window.addEventListener('keydown', (e) => {
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
if (isTextEntryTarget(e.target)) return;
const isShortcut = (e.key === 'n' || e.key === 'N') && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey;
if (!isShortcut) return;
e.preventDefault();
e.stopPropagation();
const folderCb = document.querySelector('#fileList .folder-checkbox:checked');
const targetFolder =
(folderCb && folderCb.dataset.folder) ||
getSelectedTreeFolderPath() ||
window.currentFolder ||
'root';
if (targetFolder) {
window.currentFolder = targetFolder;
}
const btn = document.getElementById('createFolderBtn');
if (btn && !btn.disabled) {
btn.click();
return;
}
const modal = document.getElementById('createFolderModal');
if (modal) {
modal.style.display = 'block';
const input = document.getElementById('newFolderName');
if (input) input.focus();
}
});
window.addEventListener('keydown', (e) => {
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
if (isTextEntryTarget(e.target)) return;
@@ -1007,7 +1380,7 @@ window.addEventListener('keydown', (e) => {
if (key === '?' || (key === '/' && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
showToast('Shortcuts: / search, ? help, Del delete, F3 preview, F4 edit, F5 copy, F6 move, F7 new folder, F8 delete.', 6000);
showToast('Shortcuts: / search, ? help, Del delete, F2 rename, F3 preview, F4 edit, F5 copy, F6 move, F7 new folder, F8 delete, Ctrl/Cmd+Shift+N new folder.', 6000);
return;
}
@@ -1898,6 +2271,14 @@ function getSelectedFolderPath() {
return cb ? cb.dataset.folder || null : null;
}
function getSelectedTreeFolderPath() {
const tree = document.getElementById('folderTreeContainer');
const opt = tree ? tree.querySelector('.folder-option.selected') : document.querySelector('.folder-option.selected');
if (!opt) return null;
const folder = opt.getAttribute('data-folder') || '';
return folder || null;
}
async function refreshSelectedFolderCaps(folder) {
if (!folder) {
window.selectedFolderCaps = null;
@@ -2161,7 +2542,7 @@ async function fetchFolderPeek(folder) {
let subfolderNames = [];
try {
const res2 = await fetch(
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`),
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0`),
{ credentials: "include" }
);
const raw2 = await safeJson(res2);
@@ -2604,9 +2985,30 @@ fetchFolderPeek(folderPath).then(result => {
} else if (isVideo) {
// --- NEW: lightweight video thumbnail ---
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
const MAX_VIDEO_PREVIEW_BYTES = 1 * 1024 * 1024 * 1024; // 1 GiB cap (just metadata anyway)
const maxVideoPreviewBytes = getMaxVideoPreviewBytes();
const fallbackMsg = t("no_preview_available") || "No preview available";
const isStillActive = () =>
hoverPreviewContext &&
hoverPreviewContext.type === "file" &&
hoverPreviewContext.file === file;
const renderVideoFallback = () => {
if (!isStillActive()) return;
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(fallbackMsg)}
</div>
`;
};
if (bytes == null || bytes <= MAX_VIDEO_PREVIEW_BYTES) {
if (bytes == null || bytes <= maxVideoPreviewBytes) {
thumbEl.style.minHeight = "140px";
const video = document.createElement("video");
@@ -2620,6 +3022,17 @@ fetchFolderPeek(folderPath).then(result => {
video.style.display = "block";
video.style.borderRadius = "6px";
let hasFrame = false;
let frameTimer = null;
const markFrame = () => {
if (!isStillActive()) return;
hasFrame = true;
if (frameTimer) {
clearTimeout(frameTimer);
frameTimer = null;
}
};
// Try to seek a tiny bit in so we don't get a black frame
video.addEventListener("loadedmetadata", () => {
try {
@@ -2631,24 +3044,22 @@ fetchFolderPeek(folderPath).then(result => {
// best effort; ignore errors
}
});
// Confirm we actually have a frame to show
video.addEventListener("loadeddata", () => {
markFrame();
});
// graceful fallback if the video can't load
video.addEventListener("error", () => {
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>
`;
renderVideoFallback();
});
frameTimer = setTimeout(() => {
if (!hasFrame && isStillActive()) {
renderVideoFallback();
}
}, 2000);
thumbEl.appendChild(video);
@@ -2660,7 +3071,7 @@ fetchFolderPeek(folderPath).then(result => {
thumbEl.appendChild(overlay);
} else {
// too big for preview → fall through to "No preview available"
renderVideoFallback();
}
}
@@ -2879,7 +3290,7 @@ export function repairBlankFolderIcons({ force = false } = {}) {
// Inline folder rows
try {
document.querySelectorAll('#fileList tr.folder-row[data-folder]').forEach(row => {
document.querySelectorAll('#fileList tr.folder-row[data-folder], #fileListSecondary tr.folder-row[data-folder]').forEach(row => {
const folder = row.getAttribute('data-folder') || '';
if (!folder) return;
const iconSpan = row.querySelector('.folder-svg');
@@ -2889,6 +3300,45 @@ export function repairBlankFolderIcons({ force = false } = {}) {
} catch (e) { /* best effort */ }
}
function forceRepaintInlineFolderIcons(listEl) {
if (!listEl) return;
try {
listEl.querySelectorAll('tr.folder-row[data-folder]').forEach(row => {
const folder = row.getAttribute('data-folder') || '';
if (!folder) return;
const iconSpan = row.querySelector('.folder-svg');
if (!iconSpan) return;
const kind = iconSpan.dataset.kind || 'empty';
iconSpan.innerHTML = folderSVG(kind, { encrypted: isEncryptedForFolderIcon(folder) });
});
syncFolderIconSizeToRowHeight();
} catch (e) { /* best effort */ }
}
function scheduleFolderIconRepair() {
try {
const kick = () => { try { repairBlankFolderIcons(); } catch (e) {} };
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
setTimeout(kick, 80);
setTimeout(kick, 250);
} catch (e) { /* ignore */ }
}
function scheduleInactivePaneFolderIconRepair() {
if (!window.dualPaneEnabled) return;
try {
const kick = () => {
try {
const list = document.getElementById('fileListSecondary');
forceRepaintInlineFolderIcons(list);
} catch (e) { /* ignore */ }
};
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
setTimeout(kick, 80);
setTimeout(kick, 250);
} catch (e) { /* ignore */ }
}
// Paint initial icon, then flip to "paper" if non-empty
function attachStripIconAsync(hostEl, fullPath, size = 28, { preserveKind = false } = {}) {
const hex = (window.folderColorMap && window.folderColorMap[fullPath]) || '#f6b84e';
@@ -2963,7 +3413,7 @@ function folderDepthScore(folder) {
async function findBestAccessibleFolder({ lastOpenedFolder } = {}) {
try {
const res = await fetch(withBase('/api/folder/getFolderList.php'), { credentials: 'include' });
const res = await fetch(withBase('/api/folder/getFolderList.php?counts=0'), { credentials: 'include' });
const data = await safeJson(res);
const names = Array.isArray(data)
? data.map(row => normalizeFolderPath(row?.folder || row)).filter(Boolean)
@@ -4270,7 +4720,7 @@ window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;
export async function loadFileList(folderParam, options = {}) {
clearInlineRenameState({ restore: false });
await initOnlyOfficeCaps();
hideHoverPreview();
const pane = normalizePaneKey(options.pane || window.activePane);
@@ -4312,7 +4762,7 @@ export async function loadFileList(folderParam, options = {}) {
{ credentials: 'include' }
);
let foldersPromise = fetch(
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`),
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0`),
{ credentials: 'include' }
);
@@ -4350,7 +4800,7 @@ export async function loadFileList(folderParam, options = {}) {
refreshCurrentFolderCaps(folder);
// refresh folders promise for the new folder context
foldersPromise = fetch(
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`),
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0`),
{ credentials: 'include' }
);
}
@@ -4576,6 +5026,10 @@ export async function loadFileList(folderParam, options = {}) {
setPaneHasContent(pane, true);
// Intentionally skip auto-restoring the inactive pane for lazy-load perf.
if (window.dualPaneEnabled) {
scheduleFolderIconRepair();
scheduleInactivePaneFolderIconRepair();
}
updateDualPaneTargetHint();
return data.files;
@@ -4861,6 +5315,9 @@ if (headerClass) {
// click → navigate, same as before
tr.addEventListener("click", e => {
hideHoverPreview();
if (tr.classList.contains('inline-rename-active') || e.target.closest(".inline-rename-input")) {
return;
}
if (e.target.closest(".folder-checkbox")) {
return;
}
@@ -4930,12 +5387,12 @@ if (headerClass) {
}
},
{ label: t("move_folder"), action: () => openMoveFolderUI(dest) },
{ label: t("rename_folder"), action: () => { window.currentFolder = dest; openRenameFolderModal(); } },
{ label: t("rename_folder"), action: () => startInlineRenameForFolderRow(tr, dest) },
{ label: t("color_folder"), action: () => openColorFolderModal(dest) },
{ label: t("folder_share"), action: () => openFolderShareModal(dest) },
{ label: t("delete_folder"), action: () => { window.currentFolder = dest; openDeleteFolderModal(); } }
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
// Defensive: repaint icon if it was blanked by the contextmenu interaction.
try {
@@ -5292,6 +5749,7 @@ async function openDefaultFileFromHover(file) {
export async function renderFileTable(folder, container, subfolders) {
clearInlineRenameState({ restore: false });
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10);
@@ -5781,6 +6239,7 @@ function getMaxImageHeight() {
}
export function renderGalleryView(folder, container) {
clearInlineRenameState({ restore: false });
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
let filteredFiles = searchFiles(searchTerm);
@@ -5847,7 +6306,7 @@ export function renderGalleryView(folder, container) {
const startIdx = (currentPage - 1) * itemsPerPage;
const pageFiles = filteredFiles.slice(startIdx, startIdx + itemsPerPage);
const maxImagePreviewBytes = getMaxImagePreviewBytes();
const MAX_GALLERY_VIDEO_PREVIEW_BYTES = 1024 * 1024 * 1024; // 1 GiB (metadata only)
const maxVideoPreviewBytes = getMaxVideoPreviewBytes();
pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
@@ -5893,7 +6352,7 @@ export function renderGalleryView(folder, container) {
}
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
if (bytes != null && bytes > MAX_GALLERY_VIDEO_PREVIEW_BYTES) {
if (bytes != null && bytes > maxVideoPreviewBytes) {
thumbnail = `<span class="material-icons gallery-icon">movie</span>`;
} else {
const maxHeight = getMaxImageHeight();
@@ -6072,7 +6531,16 @@ export function renderGalleryView(folder, container) {
// prime video thumbnails (reuse hover-preview style seek)
fileListContent.querySelectorAll('.gallery-video').forEach(video => {
const wrapper = video.closest('.gallery-video-thumb');
let hasFrame = false;
let frameTimer = null;
const cleanup = () => {
if (frameTimer) {
clearTimeout(frameTimer);
frameTimer = null;
}
};
const fallback = () => {
cleanup();
if (!wrapper) return;
wrapper.innerHTML = `<span class="material-icons gallery-icon">movie</span>`;
};
@@ -6086,13 +6554,25 @@ export function renderGalleryView(folder, container) {
// best effort only
}
};
const onData = () => {
hasFrame = true;
cleanup();
};
video.addEventListener('loadedmetadata', onMeta, { once: true });
video.addEventListener('loadeddata', onData, { once: true });
video.addEventListener('error', fallback, { once: true });
frameTimer = setTimeout(() => {
if (!hasFrame) fallback();
}, 2000);
if (video.readyState >= 1) {
onMeta();
}
if (video.readyState >= 2) {
onData();
}
});
// preview clicks (dynamic import to avoid global dependency)
+12 -2
View File
@@ -8,7 +8,7 @@ import {
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl, openShareModal } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData, downloadSelectedFilesIndividually } from './fileListView.js?v={{APP_QVER}}';
import { canEditFile, fileData, downloadSelectedFilesIndividually, startInlineRenameFromContext } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
@@ -122,6 +122,7 @@ function placeMenu(x, y) {
export function hideFileContextMenu() {
const m = qMenu();
if (m) m.hidden = true;
window.__filr_ctx_row = null;
}
function currentSelection() {
@@ -178,6 +179,7 @@ export function fileListContextMenuHandler(e) {
// Stash for click handlers
window.__filr_ctx_state = state;
window.__filr_ctx_row = row || null;
}
// --- add near top ---
@@ -197,6 +199,7 @@ function menuClickDelegate(ev) {
ev.stopPropagation();
// CLOSE MENU FIRST so it cant overlay the modal
const ctxRow = window.__filr_ctx_row || null;
hideFileContextMenu();
const action = btn.dataset.action;
@@ -228,7 +231,14 @@ function menuClickDelegate(ev) {
break;
case 'rename':
if (s.file) renameFile(s.file.name, folder);
if (s.file) {
const row = ctxRow;
if (row && typeof startInlineRenameFromContext === 'function') {
const started = startInlineRenameFromContext(s.file, row);
if (started) break;
}
renameFile(s.file.name, folder);
}
break;
case 'tag_file':
+249 -4
View File
@@ -533,7 +533,7 @@ async function chooseInitialFolder(effectiveRoot, selectedFolder) {
// 2b) Ground truth from folder list API (matches getFileList 403 behavior)
try {
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
const res = await fetch('/api/folder/getFolderList.php?counts=0', { credentials: 'include' });
const data = await res.json().catch(() => []);
const names = Array.isArray(data)
? Array.from(new Set(data.map(row => {
@@ -928,6 +928,7 @@ async function fetchChildrenOnce(folder) {
if (_childCache.has(folder)) return _childCache.get(folder);
const qs = new URLSearchParams({ folder });
qs.set('limit', String(PAGE_LIMIT));
qs.set('probe', '0');
const p = (async () => {
const res = await fetch(`/api/folder/listChildren.php?${qs.toString()}`, { method: 'GET', credentials: 'include' });
const body = await safeJson(res);
@@ -960,6 +961,7 @@ async function loadMoreChildren(folder, ulEl, moreLi) {
const qs = new URLSearchParams({ folder });
if (cursor) qs.set('cursor', cursor);
qs.set('limit', String(PAGE_LIMIT));
qs.set('probe', '0');
const res = await fetch(`/api/folder/listChildren.php?${qs.toString()}`, { method: 'GET', credentials: 'include' });
const body = await safeJson(res);
@@ -2437,7 +2439,7 @@ async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
}
},
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
{ label: t('rename_folder'), action: () => { startInlineRenameInTree(folder); } },
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
...(canEncrypt ? [{ label: 'Encrypt folder', icon: 'lock', action: () => startFolderCryptoJobFlow(folder, 'encrypt') }] : []),
...(canDecrypt ? [{ label: 'Decrypt folder', icon: 'lock_open', action: () => startFolderCryptoJobFlow(folder, 'decrypt') }] : []),
@@ -2904,6 +2906,249 @@ function bindFolderManagerContextMenu() {
/* ----------------------
Rename / Delete / Create hooks
----------------------*/
export async function renameFolderInline(oldFolder, newBaseName, opts = {}) {
const selectedFolder = oldFolder || window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
if (!opts.silent) showToast("Please select a valid folder to rename.");
return { success: false, error: "invalid_folder" };
}
const newNameBasename = String(newBaseName || "").trim();
const currentBase = selectedFolder.split("/").pop() || "";
if (!newNameBasename || newNameBasename === currentBase) {
if (!opts.silent) showToast("Please enter a valid new folder name.");
return { success: false, error: "invalid_name" };
}
const parentPath = getParentFolder(selectedFolder);
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
try {
const res = await fetchWithCsrf("/api/folder/renameFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ oldFolder: selectedFolder, newFolder: newFolderFull })
});
const data = await safeJson(res);
if (!data.success) {
const msg = data.error || "Could not rename folder";
if (!opts.silent) showToast("Error: " + msg);
return { success: false, error: msg };
}
if (!opts.silent) showToast("Folder renamed successfully!");
const oldPath = selectedFolder;
const newPath = newFolderFull;
// carry color on rename as well
await carryFolderColor(oldPath, newPath);
// migrate expansion state like move and keep parent open
migrateExpansionStateOnMove(oldPath, newPath, [parentPath]);
// refresh parent list incrementally (preserves other branches)
invalidateFolderCaches(parentPath);
clearPeekCache([parentPath, oldPath, newPath]);
const ul = getULForFolder(parentPath);
if (ul) { ul._renderedOnce = false; ul.innerHTML = ""; await ensureChildrenLoaded(parentPath, ul); }
if (parentPath === 'root') placeRecycleBinNode();
// restore any open nodes we had saved
await expandAndLoadSavedState();
// update currentFolder if we renamed the open folder or its descendants
let currentUpdated = false;
if (window.currentFolder === oldPath) {
window.currentFolder = newPath;
currentUpdated = true;
} else if (window.currentFolder && window.currentFolder.startsWith(oldPath + '/')) {
const suffix = window.currentFolder.slice(oldPath.length);
window.currentFolder = newPath + suffix;
currentUpdated = true;
}
if (currentUpdated) {
localStorage.setItem("lastOpenedFolder", window.currentFolder || newPath);
}
const selectAfter = opts.selectAfter !== false;
if (selectAfter) {
selectFolder(window.currentFolder || newPath);
} else {
try { syncFolderTreeSelection(window.currentFolder); } catch (e) { /* ignore */ }
refreshFolderIcon(parentPath);
refreshFolderIcon(newPath);
}
return { success: true, newPath };
} catch (err) {
console.error("Error renaming folder:", err);
if (!opts.silent) {
showToast("Error: " + (err && err.message ? err.message : "Could not rename folder"));
}
return { success: false, error: err && err.message ? err.message : "rename_failed" };
}
}
let inlineTreeRenameState = null;
function clearInlineTreeRenameState({ restore = true } = {}) {
const state = inlineTreeRenameState;
if (!state) return;
inlineTreeRenameState = null;
try {
if (state.input && state.input.parentNode) {
state.input.parentNode.removeChild(state.input);
}
} catch (e) { /* ignore */ }
if (restore && state.labelEl) {
state.labelEl.style.display = '';
state.labelEl.textContent = state.originalName;
}
if (state.optEl) {
state.optEl.classList.remove('inline-rename-active');
}
}
function focusTreeRenameInput(input) {
try {
input.focus();
input.select();
} catch (e) { /* ignore */ }
}
export async function startInlineRenameInTree(folderPath, targetOpt) {
const selectedFolder = folderPath || window.currentFolder || 'root';
if (!selectedFolder || selectedFolder === 'root') {
showToast("Please select a valid folder to rename.");
return false;
}
clearInlineTreeRenameState();
let opt = targetOpt || null;
if (!opt) {
try {
opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(selectedFolder)}"]`);
} catch (e) {
opt = null;
}
}
if (!opt) {
try {
await expandTreePathAsync(selectedFolder, { force: true, includeLeaf: true, persist: true });
opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(selectedFolder)}"]`);
} catch (e) {
opt = null;
}
}
if (!opt) {
showToast("Please select a valid folder to rename.");
return false;
}
if (opt.classList.contains('locked')) {
showToast(t('no_access') || "You do not have access to this resource.");
return false;
}
const labelEl = opt.querySelector('.folder-label');
if (!labelEl) return false;
const oldName = labelEl.textContent || (String(selectedFolder).split('/').pop() || '');
const input = document.createElement('input');
input.type = 'text';
input.value = oldName;
input.className = 'inline-rename-input form-control';
input.setAttribute('aria-label', t('rename_folder') || 'Rename folder');
input.style.width = '100%';
input.style.maxWidth = '100%';
input.style.fontSize = 'inherit';
input.style.padding = '2px 6px';
input.style.height = 'auto';
input.style.boxSizing = 'border-box';
labelEl.style.display = 'none';
opt.insertBefore(input, labelEl);
const state = {
folderPath: selectedFolder,
input,
labelEl,
originalName: oldName,
optEl: opt,
submitting: false
};
inlineTreeRenameState = state;
opt.classList.add('inline-rename-active');
const commit = async () => {
if (!inlineTreeRenameState || inlineTreeRenameState !== state || state.submitting) return;
const newName = String(input.value || '').trim();
if (!newName || newName === oldName) {
clearInlineTreeRenameState();
return;
}
state.submitting = true;
input.disabled = true;
try {
const result = await renameFolderInline(selectedFolder, newName);
if (result && result.success) {
clearInlineTreeRenameState({ restore: false });
return;
}
} catch (e) {
console.error('Error renaming folder:', e);
}
if (inlineTreeRenameState === state) {
state.submitting = false;
input.disabled = false;
focusTreeRenameInput(input);
}
};
const cancel = () => {
clearInlineTreeRenameState();
};
const stop = (e) => { e.stopPropagation(); };
input.addEventListener('mousedown', stop);
input.addEventListener('click', stop);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commit();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancel();
}
});
input.addEventListener('blur', () => {
if (inlineTreeRenameState === state && !state.submitting) {
commit();
}
});
requestAnimationFrame(() => {
focusTreeRenameInput(input);
});
return true;
}
export function openRenameFolderModal() {
detachFolderModalsToBody();
const selectedFolder = window.currentFolder || "root";
@@ -3091,7 +3336,7 @@ export function openMoveFolderUI(sourceFolder) {
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;
if (targetSel) {
targetSel.innerHTML = '';
fetch('/api/folder/getFolderList.php', { credentials: 'include' }).then(r => r.json()).then(list => {
fetch('/api/folder/getFolderList.php?counts=0', { credentials: 'include' }).then(r => r.json()).then(list => {
if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) list = list.map(it => it.folder);
const rootOpt = document.createElement('option'); rootOpt.value = 'root'; rootOpt.textContent = '(Root)'; targetSel.appendChild(rootOpt);
(list || []).filter(f => f && f !== 'trash' && f !== (window.currentFolder || '')).forEach(f => {
@@ -3249,7 +3494,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (renameBtn) renameBtn.addEventListener("click", () => {
const cf = window.currentFolder || "root";
if (!cf || cf === "root") { showToast("Please select a valid folder to rename."); return; }
openRenameFolderModal();
startInlineRenameInTree(cf);
});
const deleteBtn = document.getElementById("deleteFolderBtn");
+6
View File
@@ -1462,6 +1462,7 @@ class AdminController
],
'display' => [
'hoverPreviewMaxImageMb' => 8,
'hoverPreviewMaxVideoMb' => 200,
'fileListSummaryDepth' => 2,
],
];
@@ -1742,6 +1743,7 @@ if (isset($data['oidc']['allowDemote'])) {
if (!isset($merged['display']) || !is_array($merged['display'])) {
$merged['display'] = [
'hoverPreviewMaxImageMb' => 8,
'hoverPreviewMaxVideoMb' => 200,
'fileListSummaryDepth' => 2,
];
}
@@ -1749,6 +1751,10 @@ if (isset($data['oidc']['allowDemote'])) {
$lim = filter_var($data['display']['hoverPreviewMaxImageMb'], FILTER_VALIDATE_INT);
$merged['display']['hoverPreviewMaxImageMb'] = max(1, min(50, $lim !== false ? $lim : 8));
}
if (array_key_exists('hoverPreviewMaxVideoMb', $data['display'])) {
$lim = filter_var($data['display']['hoverPreviewMaxVideoMb'], FILTER_VALIDATE_INT);
$merged['display']['hoverPreviewMaxVideoMb'] = max(1, min(2048, $lim !== false ? $lim : 200));
}
if (array_key_exists('fileListSummaryDepth', $data['display'])) {
$lim = filter_var($data['display']['fileListSummaryDepth'], FILTER_VALIDATE_INT);
$merged['display']['fileListSummaryDepth'] = max(0, min(10, $lim !== false ? $lim : 2));
+61 -17
View File
@@ -1075,26 +1075,55 @@ class FileController
header("Content-Type: {$mime}");
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
// Handle HTTP Range header (single range)
// Handle HTTP Range header (single range + suffix range)
$length = $size;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') {
$start = (int)$m[1];
}
if ($m[2] !== '') {
$end = (int)$m[2];
$rangeHeader = $_SERVER['HTTP_RANGE'] ?? '';
if ($rangeHeader !== '' && preg_match('/bytes=\s*(\d*)-(\d*)/i', $rangeHeader, $m)) {
$rangeStart = $m[1];
$rangeEnd = $m[2];
if ($size <= 0) {
http_response_code(416);
header('Content-Range: bytes */0');
exit;
}
// clamp to file size
if ($start < 0) $start = 0;
if ($end < $start) $end = $start;
if ($end >= $size) $end = $size - 1;
if ($rangeStart !== '' || $rangeEnd !== '') {
if ($rangeStart === '' && $rangeEnd !== '') {
// suffix range: last N bytes
$suffixLen = (int)$rangeEnd;
if ($suffixLen <= 0) {
http_response_code(416);
header("Content-Range: bytes */{$size}");
exit;
}
$start = max($size - $suffixLen, 0);
$end = $size - 1;
} else {
$start = (int)$rangeStart;
$end = ($rangeEnd !== '') ? (int)$rangeEnd : ($size - 1);
}
$length = $end - $start + 1;
if ($start < 0 || $start >= $size || $end < $start) {
http_response_code(416);
header("Content-Range: bytes */{$size}");
exit;
}
if ($end >= $size) {
$end = $size - 1;
}
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
header("Content-Length: {$length}");
$length = $end - $start + 1;
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
header("Content-Length: {$length}");
} else {
http_response_code(200);
if ($size > 0) {
header("Content-Length: {$size}");
}
}
} else {
// no range => full file
http_response_code(200);
@@ -1299,6 +1328,8 @@ class FileController
// Images we are OK to render inline
$inlineImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico'];
$inlineVideoTypes = ['mp4', 'mkv', 'webm', 'mov', 'ogv'];
$inlineAudioTypes = ['mp3', 'wav', 'm4a', 'ogg', 'flac', 'aac', 'wma', 'opus'];
// Default mime if not provided
if (empty($mimeType)) {
@@ -1311,8 +1342,21 @@ class FileController
$mimeType = 'application/octet-stream';
$inline = false;
} else {
// Inline is allowed only for the safe allowlist; ignore inline=1 for others
$inline = in_array($ext, $inlineImageTypes, true) && $inlineParam;
$inline = false;
if ($inlineParam) {
$mimeLower = strtolower((string)$mimeType);
$isVideoMime = (strpos($mimeLower, 'video/') === 0);
$isAudioMime = (strpos($mimeLower, 'audio/') === 0) || ($mimeLower === 'application/ogg');
$isOctet = ($mimeLower === 'application/octet-stream');
if (in_array($ext, $inlineImageTypes, true)) {
$inline = true;
} elseif (in_array($ext, $inlineVideoTypes, true) && ($isVideoMime || $isOctet)) {
$inline = true;
} elseif (in_array($ext, $inlineAudioTypes, true) && ($isAudioMime || $isOctet)) {
$inline = true;
}
}
}
$portalMeta = null;
+10 -3
View File
@@ -36,9 +36,9 @@ class FolderController
return $headers;
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500, bool $probe = true): array
{
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit, $probe);
}
/** Stats for a folder (folders/files/bytes; deep totals are opt-in). */
@@ -797,6 +797,13 @@ class FolderController
header('Content-Type: application/json');
self::requireAuth();
$countsRaw = $_GET['counts'] ?? null;
$includeCounts = true;
if ($countsRaw !== null) {
$cv = strtolower((string)$countsRaw);
if ($cv === '0' || $cv === 'false' || $cv === 'no') $includeCounts = false;
}
// Optional "folder" filter (supports nested like "team/reports")
$parent = $_GET['folder'] ?? null;
if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
@@ -821,7 +828,7 @@ class FolderController
$isAdmin = self::isAdmin($perms);
// 1) Full list from model
$all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"]
$all = FolderModel::getFolderList(null, null, [], $includeCounts); // each row: ["folder","fileCount","metadataFile"]
if (!is_array($all)) {
echo json_encode([]);
exit;
+15
View File
@@ -151,11 +151,15 @@ class AdminModel
$hoverPreviewMaxImageMb = isset($displayCfg['hoverPreviewMaxImageMb'])
? (int)$displayCfg['hoverPreviewMaxImageMb']
: 8;
$hoverPreviewMaxVideoMb = isset($displayCfg['hoverPreviewMaxVideoMb'])
? (int)$displayCfg['hoverPreviewMaxVideoMb']
: 200;
$fileListSummaryDepth = isset($displayCfg['fileListSummaryDepth'])
? (int)$displayCfg['fileListSummaryDepth']
: 2;
$public['display'] = [
'hoverPreviewMaxImageMb' => max(1, min(50, $hoverPreviewMaxImageMb)),
'hoverPreviewMaxVideoMb' => max(1, min(2048, $hoverPreviewMaxVideoMb)),
'fileListSummaryDepth' => max(0, min(10, $fileListSummaryDepth)),
];
@@ -447,6 +451,7 @@ class AdminModel
if (!isset($configUpdate['display']) || !is_array($configUpdate['display'])) {
$configUpdate['display'] = [
'hoverPreviewMaxImageMb' => 8,
'hoverPreviewMaxVideoMb' => 200,
'fileListSummaryDepth' => 2,
];
} else {
@@ -454,6 +459,10 @@ class AdminModel
? (int)$configUpdate['display']['hoverPreviewMaxImageMb']
: 8;
$configUpdate['display']['hoverPreviewMaxImageMb'] = max(1, min(50, $hoverPreviewMaxImageMb));
$hoverPreviewMaxVideoMb = isset($configUpdate['display']['hoverPreviewMaxVideoMb'])
? (int)$configUpdate['display']['hoverPreviewMaxVideoMb']
: 200;
$configUpdate['display']['hoverPreviewMaxVideoMb'] = max(1, min(2048, $hoverPreviewMaxVideoMb));
$fileListSummaryDepth = isset($configUpdate['display']['fileListSummaryDepth'])
? (int)$configUpdate['display']['fileListSummaryDepth']
: 2;
@@ -695,6 +704,7 @@ class AdminModel
if (!isset($config['display']) || !is_array($config['display'])) {
$config['display'] = [
'hoverPreviewMaxImageMb' => 8,
'hoverPreviewMaxVideoMb' => 200,
'fileListSummaryDepth' => 2,
];
} else {
@@ -702,6 +712,10 @@ class AdminModel
? (int)$config['display']['hoverPreviewMaxImageMb']
: 8;
$config['display']['hoverPreviewMaxImageMb'] = max(1, min(50, $hoverPreviewMaxImageMb));
$hoverPreviewMaxVideoMb = isset($config['display']['hoverPreviewMaxVideoMb'])
? (int)$config['display']['hoverPreviewMaxVideoMb']
: 200;
$config['display']['hoverPreviewMaxVideoMb'] = max(1, min(2048, $hoverPreviewMaxVideoMb));
$fileListSummaryDepth = isset($config['display']['fileListSummaryDepth'])
? (int)$config['display']['fileListSummaryDepth']
: 2;
@@ -784,6 +798,7 @@ class AdminModel
],
'display' => [
'hoverPreviewMaxImageMb' => 8,
'hoverPreviewMaxVideoMb' => 200,
'fileListSummaryDepth' => 2,
],
'branding' => [
+28 -24
View File
@@ -303,7 +303,7 @@ class FolderModel
return rtrim($rp, DIRECTORY_SEPARATOR);
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500, bool $probe = true): array
{
$folder = ACL::normalizeFolder($folder);
$limit = max(1, min(2000, $limit));
@@ -366,28 +366,32 @@ class FolderModel
$locked = !$canView;
// ---- quick per-child stats (single-level scan, early exit) ----
$hasSubs = false; // at least one subdirectory
$nonEmpty = false; // any direct entry (file or folder)
try {
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
foreach ($it as $child) {
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
$hasSubs = null; // at least one subdirectory
$nonEmpty = null; // any direct entry (file or folder)
if ($probe) {
$hasSubs = false;
$nonEmpty = false;
try {
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
foreach ($it as $child) {
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
$nonEmpty = true;
$nonEmpty = true;
$isDir = $child->isDir();
if (!$isDir && $child->isLink()) {
$linkReal = FS::safeReal($baseReal, $child->getPathname());
$isDir = ($linkReal !== null && is_dir($linkReal));
$isDir = $child->isDir();
if (!$isDir && $child->isLink()) {
$linkReal = FS::safeReal($baseReal, $child->getPathname());
$isDir = ($linkReal !== null && is_dir($linkReal));
}
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
}
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
} catch (\Throwable $e) {
// keep defaults
}
} catch (\Throwable $e) {
// keep defaults
}
// ---------------------------------------------------------------
@@ -867,7 +871,7 @@ class FolderModel
* Retrieves the list of folders (including "root") along with file count metadata.
* (Ownership filtering is handled in the controller; this function remains unchanged.)
*/
public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array
public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = [], bool $includeCounts = true): array
{
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
@@ -878,8 +882,8 @@ class FolderModel
// root
$rootMetaFile = self::getMetadataFilePath('root');
$rootFileCount = 0;
if (file_exists($rootMetaFile)) {
$rootFileCount = null;
if ($includeCounts && file_exists($rootMetaFile)) {
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
}
@@ -893,8 +897,8 @@ class FolderModel
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
foreach ($subfolders as $folder) {
$metaFile = self::getMetadataFilePath($folder);
$fileCount = 0;
if (file_exists($metaFile)) {
$fileCount = null;
if ($includeCounts && file_exists($metaFile)) {
$metadata = json_decode(file_get_contents($metaFile), true);
$fileCount = is_array($metadata) ? count($metadata) : 0;
}