diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35a793b..4cc95e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Changelog
+## Changes 11/14/2025 (v1.9.6)
+
+release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
+
+- Resumable uploads
+ - Normalize resumable GET “test chunk” handling in `UploadModel` using `resumableChunkNumber` + `resumableIdentifier`, returning explicit `status: "found"|"not found"`.
+ - Skip CSRF checks for resumable GET tests in `UploadController`, but keep strict CSRF validation for real POST uploads with soft-fail `csrf_expired` responses.
+ - Refactor `UploadModel::handleUpload()` for chunked uploads: strict filename validation, safe folder normalization, reliable temp chunk directory creation, and robust merge with clear errors if any chunk is missing.
+ - Add `UploadModel::removeChunks()` + internal `rrmdir()` to safely clean up `resumable_…` temp folders via a dedicated controller endpoint.
+
+- Frontend resumable UX & persistence
+ - Enable `testChunks: true` for Resumable.js and wire GET checks to the new backend status logic.
+ - Track in-progress resumable files per user in `localStorage` (identifier, filename, folder, size, lastPercent, updatedAt) and show a resumable hint banner inside the Upload card with a dismiss button that clears the hints for that folder.
+ - Clamp client-side progress to max `99%` until the server confirms success, so aborted tabs still show resumable state instead of “100% done”.
+ - Improve progress UI: show upload speed, spinner while finalizing, and ensure progress elements exist even for non-standard flows (e.g., submit without prior list build).
+ - On complete success, clear the progress UI, reset the file input, cancel Resumable’s internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
+
+- Hiding resumable temp folders
+ - Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
+ - Folder tree BFS traversal (child discovery / recursion).
+ - `listChildren.php` results and child-cache hydration.
+ - The inline folder strip above the file list (also filtered in `fileListView.js`).
+
+- Folder manager context menu upgrade
+ - Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
+ - Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
+ - Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
+
+- File context menu & selection logic
+ - Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
+ - Rebuild `fileMenu.js` to:
+ - Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
+ - Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
+ - Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
+
+- Tagging system robustness
+ - Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
+ - Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
+ - Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
+ - Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
+
+- Visual polish & theming
+ - Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
+ - Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
+ - Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
+ - Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
+ - Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
+
+---
+
## Changes 11/13/2025 (v1.9.5)
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
diff --git a/public/css/styles.css b/public/css/styles.css
index 401f82e..89979be 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -20,7 +20,7 @@ img.logo{ width:50px; height:50px; display:block; } /* belt & suspenders for lo
min-height: 40px; /* reserve space */
max-width: 520px;
margin: 8px auto 0;
- border-radius: 8px;
+ border-radius: var(--menu-radius);
padding: 10px 12px;
text-align: left;
margin-bottom: 10px;
@@ -195,7 +195,7 @@ body {
min-height: 40px; /* so the label has room */
}
.header-buttons button:hover {
- background-color: rgba(255, 255, 255, 0.2);
+ background-color: rgba(122,179,255,.14);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
}@media (max-width: 600px) {
@@ -332,12 +332,12 @@ body {
background: white;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- border-radius: 4px;
+ border-radius: var(--menu-radius);
}.dark-mode #loginForm {
background-color: #2c2c2c;
color: #e0e0e0;
padding: 20px;
- border-radius: 8px;
+ border-radius: var(--menu-radius);
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2);
}.dark-mode #loginForm input {
background-color: #333;
@@ -370,7 +370,7 @@ body {
background: #fff !important;
padding: 20px;
border: 1px solid #ccc;
- border-radius: 4px;
+ border-radius: var(--menu-radius);
}/* Override modal content for dark mode */
.dark-mode #restoreFilesModal .modal-content {
background: #2c2c2c !important;
@@ -441,7 +441,7 @@ body {
background: #fff;
padding: 20px;
border: 1px solid #ccc;
- border-radius: 4px;
+ border-radius: var(--menu-radius);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 90%;
@@ -501,7 +501,7 @@ body {
background-color: #fff;
padding: 10px 20px 20px 20px;
border: 1px solid #ccc;
- border-radius: 4px !important;
+ border-radius: var(--menu-radius);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
z-index: 1100 !important;
display: flex !important;
@@ -1119,7 +1119,7 @@ body {
border: 1px solid #e0e0e0;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- border-radius: 8px;
+ border-radius: var(--menu-radius);
max-width: 100%;
padding-bottom: 10px !important;
padding-left: 5px !important;
@@ -1134,7 +1134,7 @@ body {
background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;
- border-radius: 8px;
+ border-radius: var(--menu-radius);
}#fileListContainer>h2,
#fileListContainer>.file-list-actions,
#fileListContainer>#fileList {
@@ -1393,7 +1393,7 @@ body {
max-height: 90vh;
background: #fff;
padding: 20px !important;
- border-radius: 4px;
+ border-radius: var(--menu-radius);
overflow: hidden !important;
margin: auto;
position: relative;
@@ -1706,20 +1706,94 @@ body {
transform: translateY(-3px) !important;
}#restoreFilesList li label {
margin-left: 8px !important;
- }.dark-mode #fileContextMenu {
- background-color: #2c2c2c !important;
- border: 1px solid #555 !important;
- color: #e0e0e0 !important;
- }.dark-mode #fileContextMenu div {
- color: #e0e0e0 !important;
- }#folderContextMenu {
- font-family: Arial, sans-serif;
- font-size: 14px;
- }.dark-mode #folderContextMenu {
- background-color: #2c2c2c;
- border-color: #555;
- color: #e0e0e0;
- }.drop-target-sidebar {
+ }
+ /* ===== File context menu (CSS-only visuals) ===== */
+/* Context menu visual design */
+.filr-menu{
+ position: fixed;
+ z-index: 9999;
+ min-width: 220px;
+ max-width: min(320px, 90vw);
+ height: auto; /* don't stretch */
+ max-height: calc(100vh - 16px);/* never exceed viewport; adds scroll if needed */
+ overflow: auto;
+ padding: 6px;
+ border-radius: 10px;
+ border: 1px solid var(--ctx-sep, rgba(0,0,0,.08));
+ background: var(--ctx-bg, #fff);
+ color: var(--ctx-fg, #1a1a1a);
+ box-shadow:
+ 0 8px 24px rgba(0,0,0,.18),
+ 0 2px 6px rgba(0,0,0,.10);
+}
+
+/* Light/Dark tokens (inherit from body.dark-mode you already use) */
+.filr-menu { --ctx-bg:#fff; --ctx-fg:#1a1a1a; --ctx-sep:rgba(0,0,0,.08); }
+.dark-mode .filr-menu { --ctx-bg:#2c2c2c; --ctx-fg:#eaeaea; --ctx-sep:rgba(255,255,255,.12); }
+
+/* Items */
+.filr-menu .mi{
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 10px;
+ border: 0;
+ background: transparent;
+ color: inherit; /* text color follows theme */
+ text-align: left;
+ cursor: pointer;
+ border-radius: 8px;
+ user-select: none;
+}
+.filr-menu .mi:focus{ outline: none; }
+.filr-menu .mi:hover,
+.filr-menu .mi:focus-visible{
+ background: var(--filr-row-hover-bg, rgba(122,179,255,.14));
+ box-shadow: inset 0 0 0 1px var(--filr-row-outline-hover, rgba(122,179,255,.35));
+}
+
+/* Icons = Material Icons font; inherit color so light mode isn't white */
+.filr-menu .mi .material-icons{
+ font-size: 18px;
+ line-height: 1;
+ color: currentColor; /* critical: icon color matches text (light/dark) */
+}
+
+/* Labels */
+.filr-menu .mi span{ flex: 1 1 auto; }
+
+/* Separators */
+.filr-menu .sep{
+ height: 1px;
+ margin: 6px 4px;
+ background: var(--ctx-sep);
+}
+
+/* Ensure no weird default colors on hover from BS inside the menu */
+.filr-menu, .filr-menu *{
+ --bs-body-color: inherit;
+ --bs-dropdown-link-hover-color: inherit;
+}
+
+.dark-mode #fileContextMenu {
+ background: #2c2c2c;
+ border-color: #555;
+ box-shadow: 0 8px 24px rgba(0,0,0,.45);
+}
+
+#fileContextMenu { z-index: 1039; } /* below typical modal stacks */
+#fileContextMenu[hidden] { display:none !important; pointer-events:none !important; }
+
+/* Share file-menu visuals with folder menu */
+#folderContextMenu.filr-menu {
+ max-height: min(calc(100vh - 16px), 420px);
+ overflow-y: auto;
+}
+
+/* Ensure icons adapt to theme */
+#folderContextMenu .material-icons { color: currentColor; opacity: .9; }
+ .drop-target-sidebar {
display: none;
background-color: #f8f9fa;
border-right: 2px dashed #1565C0;
@@ -1798,13 +1872,35 @@ body {
box-shadow: 0 20px 30px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
z-index: 10000;
- }#uploadCard,
- #folderManagementCard {
+ }
+ :root { --menu-radius: 12px; }
+
+.filr-menu { border-radius: var(--menu-radius); }
+
+/* Cards: match the menu rounding */
+#uploadCard,
+#folderManagementCard {
transition: transform 0.3s ease, opacity 0.3s ease;
- width: 100%;
- margin-bottom: 20px;
- min-height: 320px;
- }#uploadFolderRow.highlight {
+ width: 100%;
+ margin-bottom: 20px;
+ min-height: 320px;
+
+ border-radius: var(--menu-radius);
+ overflow: hidden; /* ensures children don’t poke past rounded edges */
+ border: 1px solid var(--card-border, #e5e7eb);
+ background: var(--card-bg, #fff);
+ box-shadow: 0 8px 24px rgba(0,0,0,.08);
+}
+
+/* Dark mode polish */
+body.dark-mode #uploadCard,
+body.dark-mode #folderManagementCard {
+ border-color: var(--card-border-dark, #3a3a3a);
+ background: var(--card-bg-dark, #2c2c2c);
+ box-shadow: 0 12px 28px rgba(0,0,0,.35);
+}
+
+ #uploadFolderRow.highlight {
min-height: 320px;
margin-bottom: 20px;
}#sidebarDropArea,
@@ -1981,8 +2077,11 @@ body {
color: #333;
}.btn-icon:hover,
.btn-icon:focus {
- background: rgba(0, 0, 0, 0.1);
+ background: var(--filr-row-hover-bg) !important;
outline: none;
+ box-shadow:
+ inset 0 1px 0 var(--filr-row-outline-hover),
+ inset 0 -1px 0 var(--filr-row-outline-hover);
}.dark-mode .btn-icon .material-icons,
.dark-mode #searchIcon .material-icons {
color: #fff;
@@ -1999,7 +2098,7 @@ body {
margin-top: 0.25rem;
background: var(--bs-body-bg, #fff);
border: 1px solid #ccc;
- border-radius: 4px;
+ border-radius: var(--menu-radius);
min-width: 150px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000;
@@ -2009,8 +2108,6 @@ body {
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
- }.user-dropdown .user-menu .item:hover {
- background: #f5f5f5;
}.user-dropdown .dropdown-caret {
border-top: 5px solid currentColor;
border-left: 5px solid transparent;
@@ -2023,8 +2120,6 @@ body {
border-color: #444;
}.dark-mode .user-dropdown .user-menu .item {
color: #e0e0e0;
- }.dark-mode .user-dropdown .user-menu .item:hover {
- background: rgba(255,255,255,0.1);
}.user-dropdown .dropdown-username {
margin: 0 8px;
font-weight: 500;
@@ -2032,6 +2127,46 @@ body {
white-space: nowrap;
}
+
+/* container polish to match menus */
+.user-dropdown .user-menu {
+ border-radius: var(--menu-radius);
+ overflow: hidden; /* keep hover corners crisp */
+ backdrop-filter: saturate(140%) blur(2px);
+}
+
+/* items: same hover treatment as context menu */
+.user-dropdown .user-menu .item {
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color .12s ease, box-shadow .12s ease;
+}
+
+/* blue hover + inset hairline outline */
+.user-dropdown .user-menu .item:hover {
+ background: var(--filr-row-hover-bg) !important;
+ box-shadow:
+ inset 0 1px 0 var(--filr-row-outline-hover),
+ inset 0 -1px 0 var(--filr-row-outline-hover);
+}
+
+/* optional: round the first/last hover edges like the menu */
+.user-dropdown .user-menu .item:first-child:hover {
+ border-top-left-radius: var(--menu-radius);
+ border-top-right-radius: var(--menu-radius);
+}
+.user-dropdown .user-menu .item:last-child:hover {
+ border-bottom-left-radius: var(--menu-radius);
+ border-bottom-right-radius: var(--menu-radius);
+}
+
+/* dark mode keeps the same hue; base surface stays dark */
+.dark-mode .user-dropdown .user-menu {
+ background: #2c2c2c;
+ border-color: #444;
+}
+
.folder-strip-container {
display: flex;
padding-top: 0px !important;
@@ -2159,7 +2294,7 @@ body.dark-mode .folder-strip-container .folder-item:hover {
border-color: #e2e2e2;
}
/* media modal polish */
-.media-modal { background: var(--panel-bg, #121212); }
+.media-modal { background: #2c2c2c; }
.media-header-bar .btn { padding: 6px 10px; }
.gallery-nav-btn { color: #fff; opacity: 0.85; }
.gallery-nav-btn:hover { opacity: 1; transform: scale(1.05); }
@@ -2476,4 +2611,41 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole {
display: inline-block;
animation: filr-spin .8s linear infinite;
}
-@keyframes filr-spin { to { transform: rotate(360deg); } }
\ No newline at end of file
+@keyframes filr-spin { to { transform: rotate(360deg); } }
+
+/* Resumable upload resume hint banner */
+#resumableDraftBanner.upload-resume-banner {
+ margin: 8px 12px 12px; /* space from card edges & content below */
+}
+
+.upload-resume-banner-inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ padding: 8px 12px;
+ border-radius: 10px;
+
+ /* Soft background that should work in light & dark */
+ background: rgba(255, 152, 0, 0.06);
+ border: 1px solid rgba(255, 152, 0, 0.55);
+
+ font-size: 0.9rem;
+}
+
+/* Icon spacing + base style */
+.upload-resume-banner-inner .material-icons {
+ font-size: 20px;
+ margin-right: 6px;
+ vertical-align: middle;
+
+ /* Make sure the icon itself is transparent */
+ background-color: transparent;
+ color: #111; /* dark icon for light mode */
+}
+
+/* Dark mode: just change the icon color */
+.dark-mode .upload-resume-banner-inner .material-icons {
+ background-color: transparent; /* stay transparent */
+ color: #f5f5f5; /* light icon for dark mode */
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index a1ac6b0..d584184 100644
--- a/public/index.html
+++ b/public/index.html
@@ -477,6 +477,26 @@
+
+
Remove User
diff --git a/public/js/fileListView.js b/public/js/fileListView.js
index 9b0cc87..8356e78 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -798,7 +798,11 @@ export async function loadFileList(folderParam) {
})
.map(p => ({ name: p.split("/").pop(), full: p }));
}
- subfolders = subfolders.filter(sf => !hidden.has(sf.name));
+
+ subfolders = subfolders.filter(sf => {
+ const lower = (sf.name || "").toLowerCase();
+ return !hidden.has(lower) && !lower.startsWith("resumable_");
+ });
let strip = document.getElementById("folderStripContainer");
if (!strip) {
@@ -958,7 +962,7 @@ export function renderFileTable(folder, container, subfolders) {
}
return `
`;
});
-
+
const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "";
diff --git a/public/js/fileMenu.js b/public/js/fileMenu.js
index 85f0d44..67da2b6 100644
--- a/public/js/fileMenu.js
+++ b/public/js/fileMenu.js
@@ -1,154 +1,246 @@
// fileMenu.js
-import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
-import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
+import { t } from './i18n.js?v={{APP_QVER}}';
+import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
+import {
+ handleDeleteSelected, handleCopySelected, handleMoveSelected,
+ handleDownloadZipSelected, handleExtractZipSelected,
+ renameFile, openCreateFileModal
+} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
-import { t } from './i18n.js?v={{APP_QVER}}';
+import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
-export function showFileContextMenu(x, y, menuItems) {
- let menu = document.getElementById("fileContextMenu");
- if (!menu) {
- menu = document.createElement("div");
- menu.id = "fileContextMenu";
- menu.style.position = "fixed";
- menu.style.backgroundColor = "#fff";
- menu.style.border = "1px solid #ccc";
- menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
- menu.style.zIndex = "9999";
- menu.style.padding = "5px 0";
- menu.style.minWidth = "150px";
- document.body.appendChild(menu);
- }
- menu.innerHTML = "";
- menuItems.forEach(item => {
- let menuItem = document.createElement("div");
- menuItem.textContent = item.label;
- menuItem.style.padding = "5px 15px";
- menuItem.style.cursor = "pointer";
- menuItem.addEventListener("mouseover", () => {
- menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
- });
- menuItem.addEventListener("mouseout", () => {
- menuItem.style.backgroundColor = "";
- });
- menuItem.addEventListener("click", () => {
- item.action();
- hideFileContextMenu();
- });
- menu.appendChild(menuItem);
+const MENU_ID = 'fileContextMenu';
+
+function qMenu() { return document.getElementById(MENU_ID); }
+function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
+
+// One-time: localize labels
+function localizeMenu() {
+ const m = qMenu(); if (!m) return;
+ const map = {
+ 'create_file': 'create_file',
+ 'delete_selected': 'delete_selected',
+ 'copy_selected': 'copy_selected',
+ 'move_selected': 'move_selected',
+ 'download_zip': 'download_zip',
+ 'extract_zip': 'extract_zip',
+ 'tag_selected': 'tag_selected',
+ 'preview': 'preview',
+ 'edit': 'edit',
+ 'rename': 'rename',
+ 'tag_file': 'tag_file'
+ };
+ Object.entries(map).forEach(([action, key]) => {
+ const el = m.querySelector(`.mi[data-action="${action}"]`);
+ if (el) setText(el, key);
});
+}
- menu.style.left = x + "px";
- menu.style.top = y + "px";
- menu.style.display = "block";
+// Show/hide items based on selection state
+function configureVisibility({ any, one, many, anyZip, canEdit }) {
+ const m = qMenu(); if (!m) return;
- const menuRect = menu.getBoundingClientRect();
- const viewportHeight = window.innerHeight;
- if (menuRect.bottom > viewportHeight) {
- let newTop = viewportHeight - menuRect.height;
- if (newTop < 0) newTop = 0;
- menu.style.top = newTop + "px";
+ const show = (sel, on) => sel.forEach(el => el.hidden = !on);
+
+ show(m.querySelectorAll('[data-when="always"]'), true);
+ show(m.querySelectorAll('[data-when="any"]'), any);
+ show(m.querySelectorAll('[data-when="one"]'), one);
+ show(m.querySelectorAll('[data-when="many"]'), many);
+ show(m.querySelectorAll('[data-when="zip"]'), anyZip);
+ show(m.querySelectorAll('[data-when="can-edit"]'), canEdit);
+
+ // Hide separators at edges or duplicates
+ cleanupSeparators(m);
+}
+
+function cleanupSeparators(menu) {
+ const kids = Array.from(menu.children);
+ let lastWasSep = true; // leading seps hidden
+ kids.forEach((el, i) => {
+ if (el.classList.contains('sep')) {
+ const hide = lastWasSep || (i === kids.length - 1);
+ el.hidden = hide || el.hidden; // keep hidden if already hidden by state
+ lastWasSep = !el.hidden;
+ } else if (!el.hidden) {
+ lastWasSep = false;
+ }
+ });
+}
+
+// Position menu within viewport
+function placeMenu(x, y) {
+ const m = qMenu(); if (!m) return;
+
+ // make visible to measure
+ m.hidden = false;
+ m.style.left = '0px';
+ m.style.top = '0px';
+
+ // force a max-height via CSS fallback if styles didn't load yet
+ const pad = 8;
+ const vh = window.innerHeight, vw = window.innerWidth;
+ const mh = Math.min(vh - pad*2, 600); // JS fallback limit
+ m.style.maxHeight = mh + 'px';
+
+ // measure now that it's flow-visible
+ const r0 = m.getBoundingClientRect();
+ let nx = x, ny = y;
+
+ // If it would overflow right, shift left
+ if (nx + r0.width > vw - pad) nx = Math.max(pad, vw - r0.width - pad);
+ // If it would overflow bottom, try placing it above the cursor
+ if (ny + r0.height > vh - pad) {
+ const above = y - r0.height - 4;
+ ny = (above >= pad) ? above : Math.max(pad, vh - r0.height - pad);
}
+
+ // Guard top/left minimums
+ nx = Math.max(pad, nx);
+ ny = Math.max(pad, ny);
+
+ m.style.left = `${nx}px`;
+ m.style.top = `${ny}px`;
}
export function hideFileContextMenu() {
- const menu = document.getElementById("fileContextMenu");
- if (menu) {
- menu.style.display = "none";
- }
+ const m = qMenu();
+ if (m) m.hidden = true;
+}
+
+function currentSelection() {
+ const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
+ // checkbox values are ESCAPED names (because buildFileTableRow used safeFileName)
+ const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
+ const escSet = new Set(selectedEsc);
+
+ // map back to real file objects by comparing escaped(f.name)
+ const files = fileData.filter(f => escSet.has(escapeHTML(f.name)));
+
+ const any = files.length > 0;
+ const one = files.length === 1;
+ const many = files.length > 1;
+ const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
+ const file = one ? files[0] : null;
+ const canEditFlag = !!(file && canEditFile(file.name));
+
+ // also return the raw names if any caller needs them
+ return {
+ files, // <— real file objects for modals
+ all: files.map(f => f.name),
+ any, one, many, anyZip,
+ file,
+ canEdit: canEditFlag
+ };
}
export function fileListContextMenuHandler(e) {
e.preventDefault();
- let row = e.target.closest("tr");
+ // Check row if needed
+ const row = e.target.closest('tr');
if (row) {
- const checkbox = row.querySelector(".file-checkbox");
- if (checkbox && !checkbox.checked) {
- checkbox.checked = true;
- updateRowHighlight(checkbox);
+ const cb = row.querySelector('.file-checkbox');
+ if (cb && !cb.checked) {
+ cb.checked = true;
+ updateRowHighlight(cb);
}
}
- const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
+ const state = currentSelection();
+ configureVisibility(state);
+ placeMenu(e.clientX, e.clientY);
- let menuItems = [
- { label: t("create_file"), action: () => openCreateFileModal() },
- { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
- { label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
- { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
- { label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
- ];
-
- if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
- menuItems.push({
- label: t("extract_zip"),
- action: () => { handleExtractZipSelected(new Event("click")); }
- });
- }
-
- if (selected.length > 1) {
- menuItems.push({
- label: t("tag_selected"),
- action: () => {
- const files = fileData.filter(f => selected.includes(f.name));
- openMultiTagModal(files);
- }
- });
- }
- else if (selected.length === 1) {
- const file = fileData.find(f => f.name === selected[0]);
-
- menuItems.push({
- label: t("preview"),
- action: () => {
- const folder = window.currentFolder || "root";
- previewFile(buildPreviewUrl(folder, file.name), file.name);
- }
- });
-
- if (canEditFile(file.name)) {
- menuItems.push({
- label: t("edit"),
- action: () => { editFile(selected[0], window.currentFolder); }
- });
- }
-
- menuItems.push({
- label: t("rename"),
- action: () => { renameFile(selected[0], window.currentFolder); }
- });
-
- menuItems.push({
- label: t("tag_file"),
- action: () => { openTagModal(file); }
- });
- }
-
- showFileContextMenu(e.clientX, e.clientY, menuItems);
+ // Stash for click handlers
+ window.__filr_ctx_state = state;
}
+// --- add near top ---
+let __ctxBoundOnce = false;
+
+function docClickClose(ev) {
+ const m = qMenu(); if (!m || m.hidden) return;
+ if (!m.contains(ev.target)) hideFileContextMenu();
+}
+function docKeyClose(ev) {
+ if (ev.key === 'Escape') hideFileContextMenu();
+}
+
+function menuClickDelegate(ev) {
+ const btn = ev.target.closest('.mi[data-action]');
+ if (!btn) return;
+ ev.stopPropagation();
+
+ // CLOSE MENU FIRST so it can’t overlay the modal
+ hideFileContextMenu();
+
+ const action = btn.dataset.action;
+ const s = window.__filr_ctx_state || currentSelection();
+ const folder = window.currentFolder || 'root';
+
+ switch (action) {
+ case 'create_file': openCreateFileModal(); break;
+ case 'delete_selected': handleDeleteSelected(new Event('click')); break;
+ case 'copy_selected': handleCopySelected(new Event('click')); break;
+ case 'move_selected': handleMoveSelected(new Event('click')); break;
+ case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
+ case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
+
+ case 'tag_selected':
+ openMultiTagModal(s.files); // s.files are the real file objects
+ break;
+
+ case 'preview':
+ if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
+ break;
+
+ case 'edit':
+ if (s.file && s.canEdit) editFile(s.file.name, folder);
+ break;
+
+ case 'rename':
+ if (s.file) renameFile(s.file.name, folder);
+ break;
+
+ case 'tag_file':
+ if (s.file) openTagModal(s.file);
+ break;
+ }
+}
+
+// keep your renderFileTable wrapper as-is
+
export function bindFileListContextMenu() {
- const fileListContainer = document.getElementById("fileList");
- if (fileListContainer) {
- fileListContainer.oncontextmenu = fileListContextMenuHandler;
+ const container = document.getElementById('fileList');
+ const menu = qMenu();
+ if (!container || !menu) return;
+
+ localizeMenu();
+
+ // Open on right click in the table
+ container.oncontextmenu = fileListContextMenuHandler;
+
+ // Bind once
+ if (!__ctxBoundOnce) {
+ document.addEventListener('click', docClickClose);
+ document.addEventListener('keydown', docKeyClose);
+ menu.addEventListener('click', menuClickDelegate); // handles actions
+ __ctxBoundOnce = true;
}
}
-document.addEventListener("click", function (e) {
- const menu = document.getElementById("fileContextMenu");
- if (menu && menu.style.display === "block") {
- hideFileContextMenu();
- }
-});
-
-// Rebind context menu after file table render.
+// Rebind after table render (keeps your original behavior)
(function () {
- const originalRenderFileTable = window.renderFileTable;
- window.renderFileTable = function (folder) {
- originalRenderFileTable(folder);
- bindFileListContextMenu();
- };
+ const orig = window.renderFileTable;
+ if (typeof orig === 'function') {
+ window.renderFileTable = function (folder) {
+ orig(folder);
+ bindFileListContextMenu();
+ };
+ } else {
+ // If not present yet, bind once DOM is ready
+ document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
+ }
})();
\ No newline at end of file
diff --git a/public/js/filePreview.js b/public/js/filePreview.js
index 07b0d3d..ceac99f 100644
--- a/public/js/filePreview.js
+++ b/public/js/filePreview.js
@@ -160,7 +160,7 @@ function ensureMediaModal() {
const root = document.documentElement;
const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode');
- const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
+ const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#2c2c2c' : '#ffffff');
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
diff --git a/public/js/fileTags.js b/public/js/fileTags.js
index bbe2f90..8b842ab 100644
--- a/public/js/fileTags.js
+++ b/public/js/fileTags.js
@@ -1,172 +1,214 @@
-// fileTags.js
-// This module provides functions for opening the tag modal,
-// adding tags to files (with a global tag store for reuse),
-// updating the file row display with tag badges,
-// filtering the file list by tag, and persisting tag data.
+// fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
-export function openTagModal(file) {
- // Create the modal element.
- let modal = document.createElement('div');
- modal.id = 'tagModal';
- modal.className = 'modal';
- modal.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- document.body.appendChild(modal);
- modal.style.display = 'block';
+// -------------------- state --------------------
+let __singleInit = false;
+let __multiInit = false;
+let currentFile = null;
- updateCustomTagDropdown();
-
- document.getElementById('closeTagModal').addEventListener('click', () => {
- modal.remove();
- });
-
- updateTagModalDisplay(file);
-
- document.getElementById('tagNameInput').addEventListener('input', (e) => {
- updateCustomTagDropdown(e.target.value);
- });
-
- document.getElementById('saveTagBtn').addEventListener('click', () => {
- const tagName = document.getElementById('tagNameInput').value.trim();
- const tagColor = document.getElementById('tagColorInput').value;
- if (!tagName) {
- alert('Please enter a tag name.');
- return;
- }
- addTagToFile(file, { name: tagName, color: tagColor });
- updateTagModalDisplay(file);
- updateFileRowTagDisplay(file);
- saveFileTags(file);
- if (window.viewMode === 'gallery') {
- renderGalleryView(window.currentFolder);
- } else {
- renderFileTable(window.currentFolder);
- }
- document.getElementById('tagNameInput').value = '';
- updateCustomTagDropdown();
- });
+// Global store (preserve existing behavior)
+window.globalTags = window.globalTags || [];
+if (localStorage.getItem('globalTags')) {
+ try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
}
-/**
- * Open a modal to tag multiple files.
- * @param {Array} files - Array of file objects to tag.
- */
-export function openMultiTagModal(files) {
- let modal = document.createElement('div');
- modal.id = 'multiTagModal';
- modal.className = 'modal';
- modal.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+// -------------------- ensure DOM (create-once-if-missing) --------------------
+function ensureSingleTagModal() {
+ // de-dupe if something already injected multiples
+ const all = document.querySelectorAll('#tagModal');
+ if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
+
+ let modal = document.getElementById('tagModal');
+ if (!modal) {
+ document.body.insertAdjacentHTML('beforeend', `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- `;
- document.body.appendChild(modal);
- modal.style.display = 'block';
+ `);
+ modal = document.getElementById('tagModal');
+ }
+ return modal;
+}
- updateMultiCustomTagDropdown();
+function ensureMultiTagModal() {
+ const all = document.querySelectorAll('#multiTagModal');
+ if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
- document.getElementById('closeMultiTagModal').addEventListener('click', () => {
- modal.remove();
+ let modal = document.getElementById('multiTagModal');
+ if (!modal) {
+ document.body.insertAdjacentHTML('beforeend', `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+ modal = document.getElementById('multiTagModal');
+ }
+ return modal;
+}
+
+// -------------------- init (bind once) --------------------
+function initSingleModalOnce() {
+ if (__singleInit) return;
+ const modal = ensureSingleTagModal();
+ const closeBtn = document.getElementById('closeTagModal');
+ const saveBtn = document.getElementById('saveTagBtn');
+ const nameInp = document.getElementById('tagNameInput');
+
+ // Close handlers
+ closeBtn?.addEventListener('click', hideTagModal);
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTagModal(); });
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) hideTagModal(); // click backdrop
});
- document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
- updateMultiCustomTagDropdown(e.target.value);
+ // Input filter for dropdown
+ nameInp?.addEventListener('input', (e) => updateCustomTagDropdown(e.target.value));
+
+ // Save handler
+ saveBtn?.addEventListener('click', () => {
+ const tagName = (document.getElementById('tagNameInput')?.value || '').trim();
+ const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000';
+ if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
+ if (!currentFile) return;
+
+ addTagToFile(currentFile, { name: tagName, color: tagColor });
+ updateTagModalDisplay(currentFile);
+ updateFileRowTagDisplay(currentFile);
+ saveFileTags(currentFile);
+
+ if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
+ else renderFileTable(window.currentFolder);
+
+ const inp = document.getElementById('tagNameInput');
+ if (inp) inp.value = '';
+ updateCustomTagDropdown('');
});
- document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
- const tagName = document.getElementById('multiTagNameInput').value.trim();
- const tagColor = document.getElementById('multiTagColorInput').value;
- if (!tagName) {
- alert('Please enter a tag name.');
- return;
- }
+ __singleInit = true;
+}
+
+function initMultiModalOnce() {
+ if (__multiInit) return;
+ const modal = ensureMultiTagModal();
+ const closeBtn = document.getElementById('closeMultiTagModal');
+ const saveBtn = document.getElementById('saveMultiTagBtn');
+ const nameInp = document.getElementById('multiTagNameInput');
+
+ closeBtn?.addEventListener('click', hideMultiTagModal);
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMultiTagModal(); });
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) hideMultiTagModal();
+ });
+
+ nameInp?.addEventListener('input', (e) => updateMultiCustomTagDropdown(e.target.value));
+
+ saveBtn?.addEventListener('click', () => {
+ const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim();
+ const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000';
+ if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
+
+ const files = (window.__multiTagFiles || []);
files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file);
saveFileTags(file);
});
- modal.remove();
- if (window.viewMode === 'gallery') {
- renderGalleryView(window.currentFolder);
- } else {
- renderFileTable(window.currentFolder);
- }
+
+ hideMultiTagModal();
+ if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
+ else renderFileTable(window.currentFolder);
});
+
+ __multiInit = true;
}
-/**
- * Update the custom dropdown for multi-tag modal.
- * Similar to updateCustomTagDropdown but includes a remove icon.
- */
+// -------------------- open/close APIs --------------------
+export function openTagModal(file) {
+ initSingleModalOnce();
+ const modal = document.getElementById('tagModal');
+ const title = document.getElementById('tagModalTitle');
+
+ currentFile = file || null;
+ if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`;
+ updateCustomTagDropdown('');
+ updateTagModalDisplay(file);
+ modal.style.display = 'block';
+}
+
+export function hideTagModal() {
+ const modal = document.getElementById('tagModal');
+ if (modal) modal.style.display = 'none';
+}
+
+export function openMultiTagModal(files) {
+ initMultiModalOnce();
+ const modal = document.getElementById('multiTagModal');
+ const title = document.getElementById('multiTagTitle');
+ window.__multiTagFiles = Array.isArray(files) ? files : [];
+ if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`;
+ updateMultiCustomTagDropdown('');
+ modal.style.display = 'block';
+}
+
+export function hideMultiTagModal() {
+ const modal = document.getElementById('multiTagModal');
+ if (modal) modal.style.display = 'none';
+}
+
+// -------------------- dropdown + UI helpers --------------------
function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
- if (filterText) {
- tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
- }
+ if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
item.style.cursor = "pointer";
item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee";
- // Display colored square and tag name with remove icon.
item.innerHTML = `
${escapeHTML(tag.name)}
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return;
- document.getElementById("multiTagNameInput").value = tag.name;
- document.getElementById("multiTagColorInput").value = tag.color;
+ const n = document.getElementById("multiTagNameInput");
+ const c = document.getElementById("multiTagColorInput");
+ if (n) n.value = tag.name;
+ if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
- dropdown.innerHTML = "
No tags available
";
+ dropdown.innerHTML = `
${t('no_tags_available') || 'No tags available'}
`;
}
}
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
- if (filterText) {
- tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
- }
+ if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return;
- document.getElementById("tagNameInput").value = tag.name;
- document.getElementById("tagColorInput").value = tag.color;
+ const n = document.getElementById("tagNameInput");
+ const c = document.getElementById("tagColorInput");
+ if (n) n.value = tag.name;
+ if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
- dropdown.innerHTML = "
No tags available
";
+ dropdown.innerHTML = `
${t('no_tags_available') || 'No tags available'}
`;
}
}
-
+
// Update the modal display to show current tags on the file.
function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags');
if (!container) return;
- container.innerHTML = '
Current Tags: ';
- if (file.tags && file.tags.length > 0) {
+ container.innerHTML = `
${t('current_tags') || 'Current Tags'}: `;
+ if (file?.tags?.length) {
file.tags.forEach(tag => {
const tagElem = document.createElement('span');
tagElem.textContent = tag.name;
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative';
-
const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer';
-
removeIcon.addEventListener('click', (e) => {
e.stopPropagation();
removeTagFromFile(file, tag.name);
});
-
tagElem.appendChild(removeIcon);
container.appendChild(tagElem);
});
} else {
- container.innerHTML += 'None';
+ container.innerHTML += (t('none') || 'None');
}
}
function removeTagFromFile(file, tagName) {
- file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
+ file.tags = (file.tags || []).filter(tg => tg.name.toLowerCase() !== tagName.toLowerCase());
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
}
-/**
- * Remove a tag from the global tag store.
- * This function updates window.globalTags and calls the backend endpoint
- * to remove the tag from the persistent store.
- */
function removeGlobalTag(tagName) {
- window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
+ window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName);
}
-// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) {
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": window.csrfToken
- },
- body: JSON.stringify({
- folder: "root",
- file: "global",
- deleteGlobal: true,
- tagToDelete: tagName,
- tags: []
- })
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
+ body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- console.log("Global tag removed:", tagName);
- if (data.globalTags) {
- window.globalTags = data.globalTags;
- localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
- updateCustomTagDropdown();
- updateMultiCustomTagDropdown();
- }
- } else {
- console.error("Error removing global tag:", data.error);
- }
- })
- .catch(err => {
- console.error("Error removing global tag:", err);
- });
+ .then(r => r.json())
+ .then(data => {
+ if (data.success && data.globalTags) {
+ window.globalTags = data.globalTags;
+ localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
+ updateCustomTagDropdown();
+ updateMultiCustomTagDropdown();
+ } else if (!data.success) {
+ console.error("Error removing global tag:", data.error);
+ }
+ })
+ .catch(err => console.error("Error removing global tag:", err));
}
-
-// Global store for reusable tags.
-window.globalTags = window.globalTags || [];
-if (localStorage.getItem('globalTags')) {
- try {
- window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
- } catch (e) { }
-}
-
-// New function to load global tags from the server's persistent JSON.
+
+// -------------------- exports kept from your original --------------------
export function loadGlobalTags() {
fetch("/api/file/getFileTag.php", { credentials: "include" })
- .then(response => {
- if (!response.ok) {
- // If the file doesn't exist, assume there are no global tags.
- return [];
- }
- return response.json();
- })
+ .then(r => r.ok ? r.json() : [])
.then(data => {
- window.globalTags = data;
+ window.globalTags = data || [];
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
updateMultiCustomTagDropdown();
});
}
-
loadGlobalTags();
-
-// Add (or update) a tag in the file object.
+
export function addTagToFile(file, tag) {
- if (!file.tags) {
- file.tags = [];
- }
- const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
- if (exists) {
- exists.color = tag.color;
- } else {
- file.tags.push(tag);
- }
- const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
+ if (!file.tags) file.tags = [];
+ const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
+ if (exists) exists.color = tag.color; else file.tags.push(tag);
+
+ const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) {
window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
}
}
-
-// Update the file row (in table view) to show tag badges.
+
export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
- console.log('Updating tags for rows:', rows);
rows.forEach(row => {
let cell = row.querySelector('.file-name-cell');
- if (cell) {
- let badgeContainer = cell.querySelector('.tag-badges');
- if (!badgeContainer) {
- badgeContainer = document.createElement('div');
- badgeContainer.className = 'tag-badges';
- badgeContainer.style.display = 'inline-block';
- badgeContainer.style.marginLeft = '5px';
- cell.appendChild(badgeContainer);
- }
- badgeContainer.innerHTML = '';
- if (file.tags && file.tags.length > 0) {
- file.tags.forEach(tag => {
- const badge = document.createElement('span');
- badge.textContent = tag.name;
- badge.style.backgroundColor = tag.color;
- badge.style.color = '#fff';
- badge.style.padding = '2px 4px';
- badge.style.marginRight = '2px';
- badge.style.borderRadius = '3px';
- badge.style.fontSize = '0.8em';
- badge.style.verticalAlign = 'middle';
- badgeContainer.appendChild(badge);
- });
- }
+ if (!cell) return;
+ let badgeContainer = cell.querySelector('.tag-badges');
+ if (!badgeContainer) {
+ badgeContainer = document.createElement('div');
+ badgeContainer.className = 'tag-badges';
+ badgeContainer.style.display = 'inline-block';
+ badgeContainer.style.marginLeft = '5px';
+ cell.appendChild(badgeContainer);
}
+ badgeContainer.innerHTML = '';
+ (file.tags || []).forEach(tag => {
+ const badge = document.createElement('span');
+ badge.textContent = tag.name;
+ badge.style.backgroundColor = tag.color;
+ badge.style.color = '#fff';
+ badge.style.padding = '2px 4px';
+ badge.style.marginRight = '2px';
+ badge.style.borderRadius = '3px';
+ badge.style.fontSize = '0.8em';
+ badge.style.verticalAlign = 'middle';
+ badgeContainer.appendChild(badge);
+ });
});
}
-
+
export function initTagSearch() {
const searchInput = document.getElementById('searchInput');
- if (searchInput) {
- let tagSearchInput = document.getElementById('tagSearchInput');
- if (!tagSearchInput) {
- tagSearchInput = document.createElement('input');
- tagSearchInput.id = 'tagSearchInput';
- tagSearchInput.placeholder = 'Filter by tag';
- tagSearchInput.style.marginLeft = '10px';
- tagSearchInput.style.padding = '5px';
- searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
- tagSearchInput.addEventListener('input', () => {
- window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
- if (window.currentFolder) {
- renderFileTable(window.currentFolder);
- }
- });
- }
- }
-}
-
-export function filterFilesByTag(files) {
- if (window.currentTagFilter && window.currentTagFilter !== '') {
- return files.filter(file => {
- if (file.tags && file.tags.length > 0) {
- return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
- }
- return false;
+ if (!searchInput) return;
+ let tagSearchInput = document.getElementById('tagSearchInput');
+ if (!tagSearchInput) {
+ tagSearchInput = document.createElement('input');
+ tagSearchInput.id = 'tagSearchInput';
+ tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
+ tagSearchInput.style.marginLeft = '10px';
+ tagSearchInput.style.padding = '5px';
+ searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
+ tagSearchInput.addEventListener('input', () => {
+ window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
+ if (window.currentFolder) renderFileTable(window.currentFolder);
});
}
- return files;
}
-
+
+export function filterFilesByTag(files) {
+ const q = (window.currentTagFilter || '').trim().toLowerCase();
+ if (!q) return files;
+ return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
+}
+
function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList");
- if (dataList) {
- dataList.innerHTML = "";
- window.globalTags.forEach(tag => {
- const option = document.createElement("option");
- option.value = tag.name;
- dataList.appendChild(option);
- });
- }
+ if (!dataList) return;
+ dataList.innerHTML = "";
+ (window.globalTags || []).forEach(tag => {
+ const option = document.createElement("option");
+ option.value = tag.name;
+ dataList.appendChild(option);
+ });
}
-
+
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root";
- const payload = {
- folder: folder,
- file: file.name,
- tags: file.tags
- };
- if (deleteGlobal && tagToDelete) {
- payload.file = "global";
- payload.deleteGlobal = true;
- payload.tagToDelete = tagToDelete;
- }
+ const payload = deleteGlobal && tagToDelete ? {
+ folder: "root",
+ file: "global",
+ deleteGlobal: true,
+ tagToDelete,
+ tags: []
+ } : { folder, file: file.name, tags: file.tags };
+
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": window.csrfToken
- },
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify(payload)
})
- .then(response => response.json())
+ .then(r => r.json())
.then(data => {
if (data.success) {
- console.log("Tags saved:", data);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
+ updateGlobalTagList();
} else {
console.error("Error saving tags:", data.error);
}
})
- .catch(err => {
- console.error("Error saving tags:", err);
- });
+ .catch(err => console.error("Error saving tags:", err));
}
\ No newline at end of file
diff --git a/public/js/folderManager.js b/public/js/folderManager.js
index 3f567d4..97245e7 100644
--- a/public/js/folderManager.js
+++ b/public/js/folderManager.js
@@ -194,7 +194,13 @@ async function findFirstAccessibleFolder(startFolder = 'root') {
const name = (typeof it === 'string') ? it : (it && it.name);
if (!name) continue;
const lower = String(name).toLowerCase();
- if (lower === 'trash' || lower === 'profile_pics') continue;
+ if (
+ lower === 'trash' ||
+ lower === 'profile_pics' ||
+ lower.startsWith('resumable_')
+ ) {
+ continue;
+ }
const child = (f === 'root') ? name : `${f}/${name}`;
if (!visited.has(child)) q.push(child);
}
@@ -728,13 +734,17 @@ async function fetchChildrenOnce(folder) {
const body = await safeJson(res);
const raw = Array.isArray(body.items) ? body.items : [];
- const items = raw
- .map(normalizeItem)
- .filter(Boolean)
- .filter(it => {
- const s = it.name.toLowerCase();
- return s !== 'trash' && s !== 'profile_pics';
- });
+const items = raw
+ .map(normalizeItem)
+ .filter(Boolean)
+ .filter(it => {
+ const s = it.name.toLowerCase();
+ return (
+ s !== 'trash' &&
+ s !== 'profile_pics' &&
+ !s.startsWith('resumable_')
+ );
+ });
const payload = { items, nextCursor: body.nextCursor ?? null };
_childCache.set(folder, payload);
@@ -757,7 +767,8 @@ async function loadMoreChildren(folder, ulEl, moreLi) {
.filter(Boolean)
.filter(it => {
const s = it.name.toLowerCase();
- return s !== 'trash' && s !== 'profile_pics';
+ return s !== 'trash' && s !== 'profile_pics' &&
+ !s.startsWith('resumable_');
});
const nextCursor = body.nextCursor ?? null;
@@ -1503,15 +1514,107 @@ selectFolder(target);
export function loadFolderList(selectedFolder) { loadFolderTree(selectedFolder); } // compat
/* ----------------------
- Context menu (minimal hook)
+ Context menu (file-menu look)
----------------------*/
+function iconForFolderLabel(lbl) {
+ if (lbl === t('create_folder')) return 'create_new_folder';
+ if (lbl === t('move_folder')) return 'drive_file_move';
+ if (lbl === t('rename_folder')) return 'drive_file_rename_outline';
+ if (lbl === t('color_folder')) return 'palette';
+ if (lbl === t('folder_share')) return 'share';
+ if (lbl === t('delete_folder')) return 'delete';
+ return 'more_horiz';
+}
+
+function getFolderMenu() {
+ let m = document.getElementById('folderManagerContextMenu');
+ if (!m) {
+ m = document.createElement('div');
+ m.id = 'folderManagerContextMenu';
+ m.className = 'filr-menu';
+ m.setAttribute('role', 'menu');
+ // position + scroll are inline so it works even before CSS loads
+ m.style.position = 'fixed';
+ m.style.minWidth = '180px';
+ m.style.maxHeight = '420px';
+ m.style.overflowY = 'auto';
+ m.hidden = true;
+
+ // Close on outside click / Esc
+ document.addEventListener('click', (ev) => {
+ if (!m.hidden && !m.contains(ev.target)) hideFolderManagerContextMenu();
+ });
+ document.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') hideFolderManagerContextMenu();
+ });
+
+ document.body.appendChild(m);
+ }
+ return m;
+}
+
+export function showFolderManagerContextMenu(x, y, menuItems) {
+ const menu = getFolderMenu();
+ menu.innerHTML = '';
+
+ // Build items (same DOM as file menu:
)
+ menuItems.forEach((item, idx) => {
+ // optional separator after first item (like file menu top block)
+ if (idx === 1) {
+ const sep = document.createElement('div');
+ sep.className = 'sep';
+ menu.appendChild(sep);
+ }
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'mi';
+ btn.setAttribute('role', 'menuitem');
+
+ const ic = document.createElement('i');
+ ic.className = 'material-icons';
+ ic.textContent = iconForFolderLabel(item.label);
+
+ const tx = document.createElement('span');
+ tx.textContent = item.label;
+
+ btn.append(ic, tx);
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ hideFolderManagerContextMenu(); // close first so it never overlays your modal
+ try { item.action && item.action(); } catch (err) { console.error(err); }
+ });
+
+ menu.appendChild(btn);
+ });
+
+ // Show + clamp to viewport
+ menu.hidden = false;
+ menu.style.left = `${x}px`;
+ menu.style.top = `${y}px`;
+
+ const r = menu.getBoundingClientRect();
+ let nx = r.left, ny = r.top;
+
+ if (r.right > window.innerWidth) nx -= (r.right - window.innerWidth + 6);
+ if (r.bottom > window.innerHeight) ny -= (r.bottom - window.innerHeight + 6);
+
+ menu.style.left = `${Math.max(6, nx)}px`;
+ menu.style.top = `${Math.max(6, ny)}px`;
+}
+
+export function hideFolderManagerContextMenu() {
+ const menu = document.getElementById('folderManagerContextMenu');
+ if (menu) menu.hidden = true;
+}
+
async function folderManagerContextMenuHandler(e) {
- const target = e.target.closest(".folder-option, .breadcrumb-link");
+ const target = e.target.closest('.folder-option, .breadcrumb-link');
if (!target) return;
e.preventDefault();
e.stopPropagation();
- // No menu on locked folders; just toggle expansion if it is a tree node
+ // Toggle-only for locked nodes
if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder);
@@ -1527,89 +1630,59 @@ async function folderManagerContextMenuHandler(e) {
return;
}
- const folder = target.getAttribute("data-folder");
+ const folder = target.getAttribute('data-folder');
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
- document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
- target.classList.add("selected");
+ document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
+ target.classList.add('selected');
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
+
const menuItems = [
- { label: t("create_folder"), action: () => {
- const modal = document.getElementById("createFolderModal");
- const input = document.getElementById("newFolderName");
- if (modal) modal.style.display = "block";
+ { label: t('create_folder'), action: () => {
+ const modal = document.getElementById('createFolderModal');
+ const input = document.getElementById('newFolderName');
+ if (modal) modal.style.display = 'block';
if (input) input.focus();
- } },
- { label: t("move_folder"), action: () => openMoveFolderUI(folder) },
- { label: t("rename_folder"), action: () => openRenameFolderModal() },
- ...(canColor ? [{ label: t("color_folder"), action: () => openColorFolderModal(folder) }] : []),
- { label: t("folder_share"), action: () => openFolderShareModal(folder) },
- { label: t("delete_folder"), action: () => openDeleteFolderModal() }
+ }},
+ { label: t('move_folder'), action: () => openMoveFolderUI(folder) },
+ { label: t('rename_folder'), action: () => openRenameFolderModal() },
+ ...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
+ { label: t('folder_share'), action: () => openFolderShareModal(folder) },
+ { label: t('delete_folder'), action: () => openDeleteFolderModal() },
];
- showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
-}
-export function showFolderManagerContextMenu(x, y, menuItems) {
- let menu = document.getElementById("folderManagerContextMenu");
- if (!menu) {
- menu = document.createElement("div");
- menu.id = "folderManagerContextMenu";
- menu.style.position = "absolute";
- menu.style.padding = "5px 0";
- menu.style.minWidth = "150px";
- menu.style.zIndex = "9999";
- document.body.appendChild(menu);
- }
- if (document.body.classList.contains("dark-mode")) {
- menu.style.backgroundColor = "#2c2c2c"; menu.style.border = "1px solid #555"; menu.style.color = "#e0e0e0";
- } else {
- menu.style.backgroundColor = "#fff"; menu.style.border = "1px solid #ccc"; menu.style.color = "#000";
- }
- menu.innerHTML = "";
- menuItems.forEach(item => {
- const it = document.createElement("div");
- it.textContent = item.label;
- it.style.padding = "5px 15px";
- it.style.cursor = "pointer";
- it.addEventListener("mouseover", () => { it.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0"; });
- it.addEventListener("mouseout", () => { it.style.backgroundColor = ""; });
- it.addEventListener("click", () => { item.action(); hideFolderManagerContextMenu(); });
- menu.appendChild(it);
- });
- menu.style.left = `${x}px`;
- menu.style.top = `${y}px`;
- menu.style.display = "block";
-}
-export function hideFolderManagerContextMenu() {
- const menu = document.getElementById("folderManagerContextMenu");
- if (menu) menu.style.display = "none";
+
+ showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
}
+
function bindFolderManagerContextMenu() {
- const tree = document.getElementById("folderTreeContainer");
+ const tree = document.getElementById('folderTreeContainer');
if (tree) {
- if (tree._ctxHandler) tree.removeEventListener("contextmenu", tree._ctxHandler, false);
+ if (tree._ctxHandler) tree.removeEventListener('contextmenu', tree._ctxHandler, false);
tree._ctxHandler = (e) => {
- const onOption = e.target.closest(".folder-option");
+ const onOption = e.target.closest('.folder-option');
if (!onOption) return;
folderManagerContextMenuHandler(e);
};
- tree.addEventListener("contextmenu", tree._ctxHandler, false);
+ tree.addEventListener('contextmenu', tree._ctxHandler, false);
}
- const title = document.getElementById("fileListTitle");
+
+ const title = document.getElementById('fileListTitle');
if (title) {
- if (title._ctxHandler) title.removeEventListener("contextmenu", title._ctxHandler, false);
+ if (title._ctxHandler) title.removeEventListener('contextmenu', title._ctxHandler, false);
title._ctxHandler = (e) => {
- const onCrumb = e.target.closest(".breadcrumb-link");
+ const onCrumb = e.target.closest('.breadcrumb-link');
if (!onCrumb) return;
folderManagerContextMenuHandler(e);
};
- title.addEventListener("contextmenu", title._ctxHandler, false);
+ title.addEventListener('contextmenu', title._ctxHandler, false);
}
}
-document.addEventListener("click", hideFolderManagerContextMenu);
+
+// document.addEventListener("click", hideFolderManagerContextMenu); // not needed anymore; handled above
/* ----------------------
Rename / Delete / Create hooks
diff --git a/public/js/upload.js b/public/js/upload.js
index dd6bec6..8fb24a2 100644
--- a/public/js/upload.js
+++ b/public/js/upload.js
@@ -6,6 +6,176 @@ import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
+// --- Lightweight tracking of in-progress resumable uploads (per user) ---
+const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
+
+function getCurrentUserKey() {
+ // Try a few globals; fall back to browser profile
+ const u =
+ (window.currentUser && String(window.currentUser)) ||
+ (window.appUser && String(window.appUser)) ||
+ (window.username && String(window.username)) ||
+ '';
+ return u || 'anon';
+}
+
+function loadResumableDraftsAll() {
+ try {
+ const raw = localStorage.getItem(RESUMABLE_DRAFTS_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ return (parsed && typeof parsed === 'object') ? parsed : {};
+ } catch (e) {
+ console.warn('Failed to read resumable drafts from localStorage', e);
+ return {};
+ }
+}
+
+function saveResumableDraftsAll(all) {
+ try {
+ localStorage.setItem(RESUMABLE_DRAFTS_KEY, JSON.stringify(all));
+ } catch (e) {
+ console.warn('Failed to persist resumable drafts to localStorage', e);
+ }
+}
+
+function getUserDraftContext() {
+ const all = loadResumableDraftsAll();
+ const userKey = getCurrentUserKey();
+ if (!all[userKey] || typeof all[userKey] !== 'object') {
+ all[userKey] = {};
+ }
+ const drafts = all[userKey];
+ return { all, userKey, drafts };
+}
+
+// Upsert / update a record for this resumable file
+function upsertResumableDraft(file, percent) {
+ if (!file || !file.uniqueIdentifier) return;
+
+ const { all, userKey, drafts } = getUserDraftContext();
+ const id = file.uniqueIdentifier;
+ const folder = window.currentFolder || 'root';
+ const name = file.fileName || file.name || 'Unnamed file';
+ const size = file.size || 0;
+
+ const prev = drafts[id] || {};
+ const p = Math.max(0, Math.min(100, Math.floor(percent || 0)));
+
+ // Avoid hammering localStorage if nothing substantially changed
+ if (prev.lastPercent !== undefined && Math.abs(p - prev.lastPercent) < 1) {
+ return;
+ }
+
+ drafts[id] = {
+ identifier: id,
+ fileName: name,
+ size,
+ folder,
+ lastPercent: p,
+ updatedAt: Date.now()
+ };
+
+ all[userKey] = drafts;
+ saveResumableDraftsAll(all);
+}
+
+// Remove a single draft by identifier
+function clearResumableDraft(identifier) {
+ if (!identifier) return;
+ const { all, userKey, drafts } = getUserDraftContext();
+ if (drafts[identifier]) {
+ delete drafts[identifier];
+ all[userKey] = drafts;
+ saveResumableDraftsAll(all);
+ }
+}
+
+// Optionally clear all drafts for the current folder (used on full success)
+function clearResumableDraftsForFolder(folder) {
+ const { all, userKey, drafts } = getUserDraftContext();
+ const f = folder || 'root';
+ let changed = false;
+ for (const [id, rec] of Object.entries(drafts)) {
+ if (!rec || typeof rec !== 'object') continue;
+ if (rec.folder === f) {
+ delete drafts[id];
+ changed = true;
+ }
+ }
+ if (changed) {
+ all[userKey] = drafts;
+ saveResumableDraftsAll(all);
+ }
+}
+
+// Show a small banner if there is any in-progress resumable upload for this folder
+function showResumableDraftBanner() {
+ const uploadCard = document.getElementById('uploadCard');
+ if (!uploadCard) return;
+
+ // Remove any existing banner first
+ const existing = document.getElementById('resumableDraftBanner');
+ if (existing && existing.parentNode) {
+ existing.parentNode.removeChild(existing);
+ }
+
+ const { drafts } = getUserDraftContext();
+ const folder = window.currentFolder || 'root';
+
+ const candidates = Object.values(drafts)
+ .filter(d =>
+ d &&
+ d.folder === folder &&
+ typeof d.lastPercent === 'number' &&
+ d.lastPercent > 0 &&
+ d.lastPercent < 100
+ )
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
+
+ if (!candidates.length) {
+ return; // nothing to show
+ }
+
+ const latest = candidates[0];
+ const count = candidates.length;
+
+ const countText =
+ count === 1
+ ? 'You have a partially uploaded file'
+ : `You have ${count} partially uploaded files. Latest:`;
+
+ const banner = document.createElement('div');
+ banner.id = 'resumableDraftBanner';
+ banner.className = 'upload-resume-banner';
+ banner.innerHTML = `
+
+ cloud_upload
+
+ ${countText}
+ ${escapeHTML(latest.fileName)}
+ (~${latest.lastPercent}%).
+ Choose it again from your device to resume.
+
+
+
+ `;
+
+ const dismissBtn = banner.querySelector('.upload-resume-dismiss-btn');
+ if (dismissBtn) {
+ dismissBtn.addEventListener('click', () => {
+ // Clear all resumable hints for this folder when the user dismisses.
+ clearResumableDraftsForFolder(folder);
+ if (banner.parentNode) {
+ banner.parentNode.removeChild(banner);
+ }
+ });
+ }
+
+ // Insert at top of uploadCard
+ uploadCard.insertBefore(banner, uploadCard.firstChild);
+}
+
/* -----------------------------------------------------
Helpers for Drag–and–Drop Folder Uploads (Original Code)
----------------------------------------------------- */
@@ -456,7 +626,7 @@ async function initResumableUpload() {
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
- testChunks: false,
+ testChunks: true,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
@@ -493,6 +663,11 @@ async function initResumableUpload() {
window.selectedFiles = [];
}
window.selectedFiles.push(file);
+
+ // Track as in-progress draft at 0%
+ upsertResumableDraft(file, 0);
+ showResumableDraftBanner();
+
const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside.
@@ -520,8 +695,40 @@ async function initResumableUpload() {
resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1
- const percent = Math.floor(progress * 100);
- const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
+ let percent = Math.floor(progress * 100);
+
+ // Never persist a full 100% from progress alone.
+ // If the tab dies here, we still want it to look resumable.
+ if (percent >= 100) percent = 99;
+
+ const li = document.querySelector(
+ `li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
+ );
+ if (li && li.progressBar) {
+ if (percent < 99) {
+ li.progressBar.style.width = percent + "%";
+
+ const elapsed = (Date.now() - li.startTime) / 1000;
+ let speed = "";
+ if (elapsed > 0) {
+ const bytesUploaded = progress * file.size;
+ const spd = bytesUploaded / elapsed;
+ if (spd < 1024) speed = spd.toFixed(0) + " B/s";
+ else if (spd < 1048576) speed = (spd / 1024).toFixed(1) + " KB/s";
+ else speed = (spd / 1048576).toFixed(1) + " MB/s";
+ }
+ li.progressBar.innerText = percent + "% (" + speed + ")";
+ } else {
+ li.progressBar.style.width = "100%";
+ li.progressBar.innerHTML =
+ 'autorenew';
+ }
+
+ const pauseResumeBtn = li.querySelector(".pause-resume-btn");
+ if (pauseResumeBtn) {
+ pauseResumeBtn.disabled = false;
+ }
+ }
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
@@ -553,6 +760,7 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
}
+ upsertResumableDraft(file, percent);
});
resumableInstance.on("fileSuccess", function (file, message) {
@@ -591,6 +799,9 @@ async function initResumableUpload() {
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
+ // This file finished successfully, remove its draft record
+ clearResumableDraft(file.uniqueIdentifier);
+ showResumableDraftBanner();
});
@@ -608,18 +819,22 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
showToast("Error uploading file: " + file.fileName);
+ // Treat errored file as no longer resumable (for now) and clear its hint
+ showResumableDraftBanner();
});
- resumableInstance.on("complete", function () {
+ resumableInstance.on("complete", function () {
// If any file is marked with an error, leave the list intact.
- const hasError = window.selectedFiles.some(f => f.isError);
+ const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => {
const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer");
- progressContainer.innerHTML = "";
+ if (progressContainer) {
+ progressContainer.innerHTML = "";
+ }
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -628,6 +843,15 @@ async function initResumableUpload() {
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
+
+ // IMPORTANT: clear Resumable's internal file list so the next upload
+ // doesn't think there are still resumable files queued.
+ if (resumableInstance) {
+ // cancel() after completion just resets internal state; no chunks are deleted server-side.
+ resumableInstance.cancel();
+ }
+ clearResumableDraftsForFolder(window.currentFolder || 'root');
+ showResumableDraftBanner();
}, 5000);
} else {
showToast("Some files failed to upload. Please check the list.");
@@ -651,11 +875,34 @@ function submitFiles(allFiles) {
const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; }
})();
+
const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file");
+ if (!progressContainer) {
+ console.warn("submitFiles called but #uploadProgressContainer not found");
+ return;
+ }
+
+ // --- Ensure there are progress list items for these files ---
+ let listItems = progressContainer.querySelectorAll("li.upload-progress-item");
+
+ if (!listItems.length) {
+ // Guarantee each file has a stable uploadIndex
+ allFiles.forEach((file, index) => {
+ if (file.uploadIndex === undefined || file.uploadIndex === null) {
+ file.uploadIndex = index;
+ }
+ });
+
+ // Build the UI rows for these files
+ // This will also set window.selectedFiles and fileInfoContainer, etc.
+ processFiles(allFiles);
+
+ // Re-query now that processFiles has populated the DOM
+ listItems = progressContainer.querySelectorAll("li.upload-progress-item");
+ }
const progressElements = {};
- const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item;
});
@@ -681,7 +928,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex];
- if (li) {
+ if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
@@ -717,12 +964,12 @@ function submitFiles(allFiles) {
return; // skip the "finishedCount++" and error/success logic for now
}
- // ─── Normal success/error handling ────────────────────────────
+ // ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
- if (li) {
+ if (li && li.progressBar) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -731,39 +978,40 @@ function submitFiles(allFiles) {
} else {
// real failure
- if (li) {
+ if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
allSucceeded = false;
}
+
if (file.isClipboard) {
setTimeout(() => {
window.selectedFiles = [];
updateFileInfoCount();
- const progressContainer = document.getElementById("uploadProgressContainer");
- if (progressContainer) progressContainer.innerHTML = "";
- const fileInfoContainer = document.getElementById("fileInfoContainer");
- if (fileInfoContainer) {
- fileInfoContainer.innerHTML = `No files selected`;
+ const pc = document.getElementById("uploadProgressContainer");
+ if (pc) pc.innerHTML = "";
+ const fic = document.getElementById("fileInfoContainer");
+ if (fic) {
+ fic.innerHTML = `No files selected`;
}
}, 5000);
}
- // ─── Only now count this chunk as finished ───────────────────
+ // ─── Only now count this upload as finished ───────────────────
finishedCount++;
-if (finishedCount === allFiles.length) {
- const succeededCount = uploadResults.filter(Boolean).length;
- const failedCount = allFiles.length - succeededCount;
+ if (finishedCount === allFiles.length) {
+ const succeededCount = uploadResults.filter(Boolean).length;
+ const failedCount = allFiles.length - succeededCount;
- setTimeout(() => {
- refreshFileList(allFiles, uploadResults, progressElements);
- }, 250);
-}
+ setTimeout(() => {
+ refreshFileList(allFiles, uploadResults, progressElements);
+ }, 250);
+ }
});
xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex];
- if (li) {
+ if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
uploadResults[file.uploadIndex] = false;
@@ -779,7 +1027,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex];
- if (li) {
+ if (li && li.progressBar) {
li.progressBar.innerText = "Aborted";
}
uploadResults[file.uploadIndex] = false;
@@ -809,38 +1057,42 @@ if (finishedCount === allFiles.length) {
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
+
let overallSuccess = true;
let succeeded = 0;
+
allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
- if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
- if (li) {
+
+ if (!uploadResults[file.uploadIndex] ||
+ (!hadRelative && !serverFiles.includes(clientFileName))) {
+ if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
overallSuccess = false;
-
} else if (li) {
succeeded++;
-
+
// Schedule removal of successful file entry after 5 seconds.
setTimeout(() => {
li.remove();
delete progressElements[file.uploadIndex];
updateFileInfoCount();
- const progressContainer = document.getElementById("uploadProgressContainer");
- if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
- const fileInput = document.getElementById("file");
- if (fileInput) fileInput.value = "";
- progressContainer.innerHTML = "";
+ const pc = document.getElementById("uploadProgressContainer");
+ if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
+ const fi = document.getElementById("file");
+ if (fi) fi.value = "";
+ pc.innerHTML = "";
adjustFolderHelpExpansionClosed();
- const fileInfoContainer = document.getElementById("fileInfoContainer");
- if (fileInfoContainer) {
- fileInfoContainer.innerHTML = `No files selected`;
+ const fic = document.getElementById("fileInfoContainer");
+ if (fic) {
+ fic.innerHTML = `No files selected`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
+ window.selectedFiles = [];
}
}, 5000);
}
@@ -850,7 +1102,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
- showToast(`${succeeded} file succeeded. Please check the list.`);
+ showToast(`${succeeded} file(s) succeeded. Please check the list.`);
}
})
.catch(error => {
@@ -859,7 +1111,6 @@ if (finishedCount === allFiles.length) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
-
});
}
}
@@ -918,17 +1169,23 @@ function initUpload() {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
-
+
if (useResumable) {
+ // New resumable batch: reset selectedFiles so the count is correct
+ window.selectedFiles = [];
+
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
- for (const f of files) resumableInstance.addFile(f);
+ for (const f of files) {
+ resumableInstance.addFile(f);
+ }
} else {
- // If still not ready (load error), fall back to your XHR path
+ // If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
+ // Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
@@ -937,27 +1194,40 @@ function initUpload() {
if (uploadForm) {
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
- const files = window.selectedFiles || (fileInput ? fileInput.files : []);
+
+ const files =
+ (Array.isArray(window.selectedFiles) && window.selectedFiles.length)
+ ? window.selectedFiles
+ : (fileInput ? Array.from(fileInput.files || []) : []);
+
if (!files || !files.length) {
showToast("No files selected.");
return;
}
-
- // Resumable path (only for picked files, not folder uploads)
- const first = files[0];
- const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
- if (useResumable && !isFolderish) {
+
+ // If we have any files queued in Resumable, treat this as a resumable upload.
+ const hasResumableFiles =
+ useResumable &&
+ resumableInstance &&
+ Array.isArray(resumableInstance.files) &&
+ resumableInstance.files.length > 0;
+
+ if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
- // ensure folder/token fresh
+ // Keep folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
+ resumableInstance.opts.query.upload_token = window.csrfToken;
+ resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
+
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
- // fallback
+ // Hard fallback – should basically never happen
submitFiles(files);
}
} else {
+ // No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files);
}
});
@@ -966,6 +1236,7 @@ function initUpload() {
if (useResumable) {
initResumableUpload();
}
+ showResumableDraftBanner();
}
export { initUpload };
diff --git a/src/controllers/UploadController.php b/src/controllers/UploadController.php
index 08773c1..9d893ec 100644
--- a/src/controllers/UploadController.php
+++ b/src/controllers/UploadController.php
@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
-class UploadController {
-
- public function handleUpload(): void {
+class UploadController
+{
+ public function handleUpload(): void
+ {
header('Content-Type: application/json');
-
- // ---- 1) CSRF (header or form field) ----
- $headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
- $received = '';
- if (!empty($headersArr['x-csrf-token'])) {
- $received = trim($headersArr['x-csrf-token']);
- } elseif (!empty($_POST['csrf_token'])) {
- $received = trim($_POST['csrf_token']);
- } elseif (!empty($_POST['upload_token'])) {
- // legacy alias
- $received = trim($_POST['upload_token']);
+
+ $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+ $requestParams = ($method === 'GET') ? $_GET : $_POST;
+
+ // Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
+ $isResumableTest =
+ ($method === 'GET'
+ && isset($requestParams['resumableChunkNumber'])
+ && isset($requestParams['resumableIdentifier']));
+
+ // ---- 1) CSRF (skip for resumable GET tests – Resumable only cares about HTTP status) ----
+ if (!$isResumableTest) {
+ $headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
+ $received = '';
+
+ if (!empty($headersArr['x-csrf-token'])) {
+ $received = trim($headersArr['x-csrf-token']);
+ } elseif (!empty($requestParams['csrf_token'])) {
+ $received = trim((string)$requestParams['csrf_token']);
+ } elseif (!empty($requestParams['upload_token'])) {
+ // legacy alias
+ $received = trim((string)$requestParams['upload_token']);
+ }
+
+ if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
+ // Soft-fail so client can retry with refreshed token
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ http_response_code(200);
+ echo json_encode([
+ 'csrf_expired' => true,
+ 'csrf_token' => $_SESSION['csrf_token'],
+ ]);
+ return;
+ }
}
-
- if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
- // Soft-fail so client can retry with refreshed token
- $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
- http_response_code(200);
- echo json_encode([
- 'csrf_expired' => true,
- 'csrf_token' => $_SESSION['csrf_token']
- ]);
- return;
- }
-
+
// ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
-
+
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
-
+
// Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
-
+
// ---- 3) Folder-level WRITE permission (ACL) ----
- // Always require client to send the folder; fall back to GET if needed.
- $folderParam = isset($_POST['folder'])
- ? (string)$_POST['folder']
- : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
+ // Prefer the unified param array, fall back to GET only if needed.
+ $folderParam = isset($requestParams['folder'])
+ ? (string)$requestParams['folder']
+ : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
- // Decode %xx (e.g., "test%20folder") then normalize
- $folderParam = rawurldecode($folderParam);
- $targetFolder = ACL::normalizeFolder($folderParam);
+ // Decode %xx (e.g., "test%20folder") then normalize
+ $folderParam = rawurldecode($folderParam);
+ $targetFolder = ACL::normalizeFolder($folderParam);
- // Admins bypass folder canWrite checks
- $username = (string)($_SESSION['username'] ?? '');
- $userPerms = loadUserPermissions($username) ?: [];
- $isAdmin = ACL::isAdmin($userPerms);
+ // Admins bypass folder canWrite checks
+ if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
+ http_response_code(403);
+ echo json_encode([
+ 'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
+ ]);
+ return;
+ }
- if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
- http_response_code(403);
- echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
- return;
+ // ---- 4) Delegate to model (force the sanitized folder) ----
+ $requestParams['folder'] = $targetFolder;
+ // Keep legacy behavior for anything still reading $_POST directly
+ $_POST['folder'] = $targetFolder;
+
+ $result = UploadModel::handleUpload($requestParams, $_FILES);
+
+ // ---- 5) Special handling for Resumable.js GET tests ----
+ // Resumable only inspects HTTP status:
+ // 200 => chunk exists (skip)
+ // 404/other => chunk missing (upload)
+ if ($isResumableTest && isset($result['status'])) {
+ if ($result['status'] === 'found') {
+ http_response_code(200);
+ } else {
+ http_response_code(202); // 202 Accepted = chunk not found
+ }
+ echo json_encode($result);
+ return;
+ }
+
+ // ---- 6) Normal response handling ----
+ if (isset($result['error'])) {
+ http_response_code(400);
+ echo json_encode($result);
+ return;
+ }
+
+ if (isset($result['status'])) {
+ echo json_encode($result);
+ return;
+ }
+
+ echo json_encode([
+ 'success' => $result['success'] ?? 'File uploaded successfully',
+ 'newFilename' => $result['newFilename'] ?? null,
+ ]);
}
- // ---- 4) Delegate to model (force the sanitized folder) ----
- $_POST['folder'] = $targetFolder; // in case model reads superglobal
- $post = $_POST;
- $post['folder'] = $targetFolder;
+ public function removeChunks(): void
+ {
+ header('Content-Type: application/json');
- $result = UploadModel::handleUpload($post, $_FILES);
+ $receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
+ if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
+ http_response_code(403);
+ echo json_encode(['error' => 'Invalid CSRF token']);
+ return;
+ }
- // ---- 5) Response (unchanged) ----
- if (isset($result['error'])) {
- http_response_code(400);
- echo json_encode($result);
- return;
+ if (!isset($_POST['folder'])) {
+ http_response_code(400);
+ echo json_encode(['error' => 'No folder specified']);
+ return;
+ }
+
+ $folderRaw = (string)$_POST['folder'];
+ $folder = ACL::normalizeFolder(rawurldecode($folderRaw));
+
+ echo json_encode(UploadModel::removeChunks($folder));
}
- if (isset($result['status'])) {
- echo json_encode($result);
- return;
- }
-
- echo json_encode([
- 'success' => 'File uploaded successfully',
- 'newFilename' => $result['newFilename'] ?? null
- ]);
-}
-
- public function removeChunks(): void {
- header('Content-Type: application/json');
-
- $receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
- if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
- http_response_code(403);
- echo json_encode(['error' => 'Invalid CSRF token']);
- return;
- }
-
- if (!isset($_POST['folder'])) {
- http_response_code(400);
- echo json_encode(['error' => 'No folder specified']);
- return;
- }
-
- $folderRaw = (string)$_POST['folder'];
- $folder = ACL::normalizeFolder(rawurldecode($folderRaw));
-
- echo json_encode(UploadModel::removeChunks($folder));
-}
}
\ No newline at end of file
diff --git a/src/models/UploadModel.php b/src/models/UploadModel.php
index 1413cb6..ea3d2af 100644
--- a/src/models/UploadModel.php
+++ b/src/models/UploadModel.php
@@ -3,14 +3,17 @@
require_once PROJECT_ROOT . '/config/config.php';
-class UploadModel {
-
- private static function sanitizeFolder(string $folder): string {
+class UploadModel
+{
+ private static function sanitizeFolder(string $folder): string
+ {
// decode "%20", normalise slashes & trim via ACL helper
$f = ACL::normalizeFolder(rawurldecode($folder));
// model uses '' to represent root
- if ($f === 'root') return '';
+ if ($f === 'root') {
+ return '';
+ }
// forbid dot segments / empty parts
foreach (explode('/', $f) as $seg) {
@@ -28,9 +31,13 @@ class UploadModel {
return $f; // safe, normalised, with spaces allowed
}
- public static function handleUpload(array $post, array $files): array {
- // --- GET resumable test (make folder handling consistent)
- if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
+ public static function handleUpload(array $post, array $files): array
+ {
+ // --- GET resumable test (make folder handling consistent) ---
+ if (
+ (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
+ && isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
+ ) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
@@ -38,15 +45,16 @@ class UploadModel {
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
- . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
+ . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
- $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
+ $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
- return ["status" => file_exists($chunkFile) ? "found" : "not found"];
+
+ return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
}
- // --- CHUNKED ---
+ // --- CHUNKED (Resumable.js POST uploads) ---
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks'];
@@ -54,109 +62,126 @@ class UploadModel {
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
- return ["error" => "Invalid file name: $resumableFilename"];
+ return ['error' => "Invalid file name: $resumableFilename"];
}
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if (empty($files['file']) || !isset($files['file']['name'])) {
- return ["error" => "No files received"];
+ return ['error' => 'No files received'];
}
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
- . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
+ . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
- return ["error" => "Failed to create upload directory"];
+ return ['error' => 'Failed to create upload directory'];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
- return ["error" => "Failed to create temporary chunk directory"];
+ return ['error' => 'Failed to create temporary chunk directory'];
}
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) {
- return ["error" => "Upload error on chunk $chunkNumber"];
+ return ['error' => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
$tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
- return ["error" => "Failed to move uploaded chunk $chunkNumber"];
+ return ['error' => "Failed to move uploaded chunk $chunkNumber"];
}
- // all chunks present?
+ // All chunks present?
for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) {
- return ["status" => "chunk uploaded"];
+ return ['status' => 'chunk uploaded'];
}
}
- // merge
+ // Merge
$targetPath = $baseUploadDir . $resumableFilename;
- if (!$out = fopen($targetPath, "wb")) {
- return ["error" => "Failed to open target file for writing"];
+ if (!$out = fopen($targetPath, 'wb')) {
+ return ['error' => 'Failed to open target file for writing'];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
- if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
- if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
- while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
+ if (!file_exists($chunkPath)) {
+ fclose($out);
+ return ['error' => "Chunk $i missing during merge"];
+ }
+ if (!$in = fopen($chunkPath, 'rb')) {
+ fclose($out);
+ return ['error' => "Failed to open chunk $i"];
+ }
+ while ($buff = fread($in, 4096)) {
+ fwrite($out, $buff);
+ }
fclose($in);
}
fclose($out);
- // metadata
- $metadataKey = ($folderSan === '') ? "root" : $folderSan;
+ // Metadata
+ $metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
- $uploader = $_SESSION['username'] ?? "Unknown";
- $collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
- if (!is_array($collection)) $collection = [];
+ $uploader = $_SESSION['username'] ?? 'Unknown';
+ $collection = file_exists($metadataFile)
+ ? json_decode(file_get_contents($metadataFile), true)
+ : [];
+ if (!is_array($collection)) {
+ $collection = [];
+ }
if (!isset($collection[$resumableFilename])) {
- $collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
+ $collection[$resumableFilename] = [
+ 'uploaded' => $uploadedDate,
+ 'uploader' => $uploader,
+ ];
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
}
- // cleanup temp
+ // Cleanup temp
self::rrmdir($tempDir);
- return ["success" => "File uploaded successfully"];
+ return ['success' => 'File uploaded successfully'];
}
- // --- NON-CHUNKED ---
+ // --- NON-CHUNKED (drag-and-drop / folder uploads) ---
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
- . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
+ . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
- return ["error" => "Failed to create upload directory"];
+ return ['error' => 'Failed to create upload directory'];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
- foreach ($files["file"]["name"] as $index => $fileName) {
+ foreach ($files['file']['name'] as $index => $fileName) {
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
- return ["error" => "Error uploading file"];
+ return ['error' => 'Error uploading file'];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
- return ["error" => "Invalid file name: " . $fileName];
+ return ['error' => 'Invalid file name: ' . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
- $relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
+ $relativePath = is_array($post['relativePath'])
+ ? ($post['relativePath'][$index] ?? '')
+ : $post['relativePath'];
}
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
@@ -164,34 +189,41 @@ class UploadModel {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
- . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
+ . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
- return ["error" => "Failed to create subfolder: " . $uploadDir];
+ return ['error' => 'Failed to create subfolder: ' . $uploadDir];
}
$targetPath = $uploadDir . $safeFileName;
- if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
- return ["error" => "Error uploading file"];
+ if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
+ return ['error' => 'Error uploading file'];
}
- $metadataKey = ($folderSan === '') ? "root" : $folderSan;
+ $metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
- $metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
- if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
+ $metadataCollection[$metadataKey] = file_exists($metadataFile)
+ ? json_decode(file_get_contents($metadataFile), true)
+ : [];
+ if (!is_array($metadataCollection[$metadataKey])) {
+ $metadataCollection[$metadataKey] = [];
+ }
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
- $uploader = $_SESSION['username'] ?? "Unknown";
- $metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
+ $uploader = $_SESSION['username'] ?? 'Unknown';
+ $metadataCollection[$metadataKey][$safeFileName] = [
+ 'uploaded' => $uploadedDate,
+ 'uploader' => $uploader,
+ ];
$metadataChanged[$metadataKey] = true;
}
}
@@ -204,17 +236,17 @@ class UploadModel {
}
}
- return ["success" => "Files uploaded successfully"];
+ return ['success' => 'Files uploaded successfully'];
}
-
- /**
+ /**
* Recursively removes a directory and its contents.
*
* @param string $dir The directory to remove.
* @return void
*/
- private static function rrmdir(string $dir): void {
+ private static function rrmdir(string $dir): void
+ {
if (!is_dir($dir)) {
return;
}
@@ -231,7 +263,7 @@ class UploadModel {
}
rmdir($dir);
}
-
+
/**
* Removes the temporary chunk directory for resumable uploads.
*
@@ -240,25 +272,26 @@ class UploadModel {
* @param string $folder The folder name provided (URL-decoded).
* @return array Returns a status array indicating success or error.
*/
- public static function removeChunks(string $folder): array {
+ public static function removeChunks(string $folder): array
+ {
$folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) {
- return ["error" => "Invalid folder name"];
+ return ['error' => 'Invalid folder name'];
}
-
+
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
if (!is_dir($tempDir)) {
- return ["success" => true, "message" => "Temporary folder already removed."];
+ return ['success' => true, 'message' => 'Temporary folder already removed.'];
}
-
+
self::rrmdir($tempDir);
-
+
if (!is_dir($tempDir)) {
- return ["success" => true, "message" => "Temporary folder removed."];
- } else {
- return ["error" => "Failed to remove temporary folder."];
+ return ['success' => true, 'message' => 'Temporary folder removed.'];
}
+
+ return ['error' => 'Failed to remove temporary folder.'];
}
}
\ No newline at end of file