mirror of
https://github.com/error311/FileRise.git
synced 2026-05-18 03:33:28 -05:00
release(v2.13.0): inline rename + video preview limits + folder tree perf (see #79)
This commit is contained in:
@@ -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 can’t 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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">×</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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ const DEFAULT_SUPPORTERS = [
|
||||
'Rob Parker',
|
||||
'Aaron W.',
|
||||
'C-Fu',
|
||||
'peterchia'
|
||||
'peterchia',
|
||||
'Edisto Pirates of SC'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 can’t 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
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user